From ffe0eec4072df9de5970570ec07068dbd7961ff2 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Sat, 3 May 2025 13:47:59 +0100 Subject: [PATCH 01/29] Introduce the kata and document steps --- test-pnpm/morning_routine/README.md | 0 test-pnpm/shopping-cart-kata/README.md | 222 +++++++++++++++++++++++++ 2 files changed, 222 insertions(+) delete mode 100644 test-pnpm/morning_routine/README.md create mode 100644 test-pnpm/shopping-cart-kata/README.md diff --git a/test-pnpm/morning_routine/README.md b/test-pnpm/morning_routine/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/test-pnpm/shopping-cart-kata/README.md b/test-pnpm/shopping-cart-kata/README.md new file mode 100644 index 0000000..222b9bd --- /dev/null +++ b/test-pnpm/shopping-cart-kata/README.md @@ -0,0 +1,222 @@ +# 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.88 € | +| 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 + +You could change this API this is only for example purposes. + +**Approach 1 passing objects as arguments could be DTO** + +```java +public interface ShoppingCart { + public void addItem(Product product); + public void deleteItem(Product product); + public void applyDiscount(Discount discount) + public void printShoppingCart(); +} +``` + +**Approach 2 passing primitives as arguments** + +```java +public interface ShoppingCart { + public void addItem(String productName); + public void deleteItem(String productName); + public void applyDiscount(Double discount) + public void printShoppingCart(); +} + +Approach 3 passing primitives as arguments and returning a DTO +public interface ShoppingCart { + public void addItem(String productName); + public void deleteItem(String productName); + public void applyDiscount(Double discount) + public ShoppingCartList getShoppingCart(); +} +``` + +### 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/remove 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 | + +### 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. From 6c8a63df3aa2f9cfea71e99ed78e668f13940102 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Sat, 3 May 2025 13:49:08 +0100 Subject: [PATCH 02/29] Iteration1: Create failing test --- test-pnpm/shopping-cart-kata/product.should.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 test-pnpm/shopping-cart-kata/product.should.test.ts diff --git a/test-pnpm/shopping-cart-kata/product.should.test.ts b/test-pnpm/shopping-cart-kata/product.should.test.ts new file mode 100644 index 0000000..ebc8c5e --- /dev/null +++ b/test-pnpm/shopping-cart-kata/product.should.test.ts @@ -0,0 +1,6 @@ +describe('Product should', () => { + it('calculate price per unit with revenue and rounding', () => { + const product = new Product('Iceberg πŸ₯¬', 1.55, 0.15, TaxRate.Normal); + expect(product.pricePerUnit).toBe(1.79); + }); +}); From c11d67ba4b36a7485b495841fee4f748de1a2ca7 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Sat, 3 May 2025 14:06:26 +0100 Subject: [PATCH 03/29] Iteration1: Make test pass for a single product --- .../shopping-cart-kata/product.should.test.ts | 4 +++- test-pnpm/shopping-cart-kata/product.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 test-pnpm/shopping-cart-kata/product.ts diff --git a/test-pnpm/shopping-cart-kata/product.should.test.ts b/test-pnpm/shopping-cart-kata/product.should.test.ts index ebc8c5e..a10e7e6 100644 --- a/test-pnpm/shopping-cart-kata/product.should.test.ts +++ b/test-pnpm/shopping-cart-kata/product.should.test.ts @@ -1,6 +1,8 @@ +import { Product } from './product'; + describe('Product should', () => { it('calculate price per unit with revenue and rounding', () => { - const product = new Product('Iceberg πŸ₯¬', 1.55, 0.15, TaxRate.Normal); + const product = new Product('Iceberg πŸ₯¬', 1.55, 0.15); expect(product.pricePerUnit).toBe(1.79); }); }); diff --git a/test-pnpm/shopping-cart-kata/product.ts b/test-pnpm/shopping-cart-kata/product.ts new file mode 100644 index 0000000..bb65b8d --- /dev/null +++ b/test-pnpm/shopping-cart-kata/product.ts @@ -0,0 +1,16 @@ +export class Product { + constructor( + public name: string, + public cost: number, + public revenueMargin: number, + ) {} + + get pricePerUnit(): number { + const price = this.cost * (1 + this.revenueMargin); + return this.roundUp(price); + } + + private roundUp(value: number): number { + return Math.ceil(value * 100) / 100; + } +} From 403e5c0ed040a0e64fc1f513c661235af87d0a43 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Sat, 3 May 2025 14:18:06 +0100 Subject: [PATCH 04/29] Iteration1: Make test pass per unit two products --- .../shopping-cart-kata/product.should.test.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/test-pnpm/shopping-cart-kata/product.should.test.ts b/test-pnpm/shopping-cart-kata/product.should.test.ts index a10e7e6..0bb2870 100644 --- a/test-pnpm/shopping-cart-kata/product.should.test.ts +++ b/test-pnpm/shopping-cart-kata/product.should.test.ts @@ -1,8 +1,24 @@ import { Product } from './product'; describe('Product should', () => { - it('calculate price per unit with revenue and rounding', () => { - const product = new Product('Iceberg πŸ₯¬', 1.55, 0.15); - expect(product.pricePerUnit).toBe(1.79); - }); + test.each([ + { + name: 'Iceberg πŸ₯¬', + cost: 1.55, + revenueMargin: 0.15, + expected: 1.79, + }, + { + name: 'Tomato πŸ…', + cost: 2.5, + revenueMargin: 0.2, + expected: 3, + }, + ])( + 'calculate price per unit for $name to be $expected', + ({ name, cost, revenueMargin, expected }) => { + const product = new Product(name, cost, revenueMargin); + expect(product.pricePerUnit).toBe(expected); + }, + ); }); From c5a9f0d697cb3f9deea81511959a203f603f5733 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Sat, 3 May 2025 14:25:28 +0100 Subject: [PATCH 05/29] Iteration1: Make test pass per unit for the rest --- .../shopping-cart-kata/product.should.test.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/test-pnpm/shopping-cart-kata/product.should.test.ts b/test-pnpm/shopping-cart-kata/product.should.test.ts index 0bb2870..8bb81b3 100644 --- a/test-pnpm/shopping-cart-kata/product.should.test.ts +++ b/test-pnpm/shopping-cart-kata/product.should.test.ts @@ -10,9 +10,27 @@ describe('Product should', () => { }, { name: 'Tomato πŸ…', - cost: 2.5, - revenueMargin: 0.2, - expected: 3, + cost: 0.52, + revenueMargin: 0.15, + expected: 0.6, + }, + { + name: 'Chicken πŸ—', + cost: 1.34, + revenueMargin: 0.12, + expected: 1.51, + }, + { + name: 'Bread 🍞', + cost: 0.71, + revenueMargin: 0.12, + expected: 0.8, + }, + { + name: 'Corn 🌽', + cost: 1.21, + revenueMargin: 0.12, + expected: 1.36, }, ])( 'calculate price per unit for $name to be $expected', From 3f925c03aa57616088cc3a9049c7dfef42e215df Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Sat, 3 May 2025 14:29:17 +0100 Subject: [PATCH 06/29] Iteration1: Failing test for the final price --- test-pnpm/shopping-cart-kata/product.should.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test-pnpm/shopping-cart-kata/product.should.test.ts b/test-pnpm/shopping-cart-kata/product.should.test.ts index 8bb81b3..06ccd90 100644 --- a/test-pnpm/shopping-cart-kata/product.should.test.ts +++ b/test-pnpm/shopping-cart-kata/product.should.test.ts @@ -39,4 +39,9 @@ describe('Product should', () => { expect(product.pricePerUnit).toBe(expected); }, ); + + it('calculate the final price with VAT rounded up', () => { + const product = new Product('Iceberg πŸ₯¬', 1.55, 0.15, TaxRate.Normal); + expect(product.finalPrice).toBe(2.17); + }); }); From f8b75222c3a6323ed49b0facec3c132a16cdfcfc Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Sat, 3 May 2025 22:07:09 +0100 Subject: [PATCH 07/29] Iteration1: Passing test for the final price with normal tax --- test-pnpm/shopping-cart-kata/TaxRate.ts | 3 +++ test-pnpm/shopping-cart-kata/product.should.test.ts | 3 ++- test-pnpm/shopping-cart-kata/product.ts | 6 ++++++ 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 test-pnpm/shopping-cart-kata/TaxRate.ts diff --git a/test-pnpm/shopping-cart-kata/TaxRate.ts b/test-pnpm/shopping-cart-kata/TaxRate.ts new file mode 100644 index 0000000..1d8fcda --- /dev/null +++ b/test-pnpm/shopping-cart-kata/TaxRate.ts @@ -0,0 +1,3 @@ +export enum TaxRate { + Normal = 0.21, +} diff --git a/test-pnpm/shopping-cart-kata/product.should.test.ts b/test-pnpm/shopping-cart-kata/product.should.test.ts index 06ccd90..3b6e69d 100644 --- a/test-pnpm/shopping-cart-kata/product.should.test.ts +++ b/test-pnpm/shopping-cart-kata/product.should.test.ts @@ -1,4 +1,5 @@ import { Product } from './product'; +import { TaxRate } from './TaxRate'; describe('Product should', () => { test.each([ @@ -40,7 +41,7 @@ describe('Product should', () => { }, ); - it('calculate the final price with VAT rounded up', () => { + it('calculate the final price with normal tax rounded up', () => { const product = new Product('Iceberg πŸ₯¬', 1.55, 0.15, TaxRate.Normal); expect(product.finalPrice).toBe(2.17); }); diff --git a/test-pnpm/shopping-cart-kata/product.ts b/test-pnpm/shopping-cart-kata/product.ts index bb65b8d..719525f 100644 --- a/test-pnpm/shopping-cart-kata/product.ts +++ b/test-pnpm/shopping-cart-kata/product.ts @@ -3,6 +3,7 @@ export class Product { public name: string, public cost: number, public revenueMargin: number, + public taxRate: number = 0, ) {} get pricePerUnit(): number { @@ -10,6 +11,11 @@ export class Product { return this.roundUp(price); } + get finalPrice(): number { + const price = this.pricePerUnit * (1 + this.taxRate); + return this.roundUp(price); + } + private roundUp(value: number): number { return Math.ceil(value * 100) / 100; } From c8a6f25bbec788a6a76f90db0d3c9e10a1400407 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Sat, 3 May 2025 22:15:28 +0100 Subject: [PATCH 08/29] Iteration1: Passing test for the final price with first necessity tax --- test-pnpm/shopping-cart-kata/README.md | 6 +++--- test-pnpm/shopping-cart-kata/TaxRate.ts | 1 + test-pnpm/shopping-cart-kata/product.should.test.ts | 7 ++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/test-pnpm/shopping-cart-kata/README.md b/test-pnpm/shopping-cart-kata/README.md index 222b9bd..7ae1b1f 100644 --- a/test-pnpm/shopping-cart-kata/README.md +++ b/test-pnpm/shopping-cart-kata/README.md @@ -11,12 +11,12 @@ We are building a shopping cart for an online grocery shop. The idea of this kat ### List of products -| **Name** | **Cost** | **% Revenue** | **Price per unit** | **Tax** | **Final price** | -| ---------- | -------- | ------------- | ------------------ | --------------------- | --------------- | +| **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.88 € | +| 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 diff --git a/test-pnpm/shopping-cart-kata/TaxRate.ts b/test-pnpm/shopping-cart-kata/TaxRate.ts index 1d8fcda..42f84f8 100644 --- a/test-pnpm/shopping-cart-kata/TaxRate.ts +++ b/test-pnpm/shopping-cart-kata/TaxRate.ts @@ -1,3 +1,4 @@ export enum TaxRate { Normal = 0.21, + FirstNecessity = 0.1, } diff --git a/test-pnpm/shopping-cart-kata/product.should.test.ts b/test-pnpm/shopping-cart-kata/product.should.test.ts index 3b6e69d..a674e84 100644 --- a/test-pnpm/shopping-cart-kata/product.should.test.ts +++ b/test-pnpm/shopping-cart-kata/product.should.test.ts @@ -41,8 +41,13 @@ describe('Product should', () => { }, ); - it('calculate the final price with normal tax rounded up', () => { + it('calculate Iceberg πŸ₯¬ final price with normal vat rounded up', () => { const product = new Product('Iceberg πŸ₯¬', 1.55, 0.15, TaxRate.Normal); expect(product.finalPrice).toBe(2.17); }); + + it('calculate Bread 🍞 final price with first necessity vat rounded up', () => { + const product = new Product('Bread 🍞', 0.71, 0.12, TaxRate.FirstNecessity); + expect(product.finalPrice).toBe(0.89); + }); }); From 880e6bae719842126e74808604fb3a9b0615b5d6 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Sat, 3 May 2025 22:30:41 +0100 Subject: [PATCH 09/29] Iteration1: Full coverage --- .../shopping-cart-kata/product.should.test.ts | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/test-pnpm/shopping-cart-kata/product.should.test.ts b/test-pnpm/shopping-cart-kata/product.should.test.ts index a674e84..ea743e8 100644 --- a/test-pnpm/shopping-cart-kata/product.should.test.ts +++ b/test-pnpm/shopping-cart-kata/product.should.test.ts @@ -41,13 +41,47 @@ describe('Product should', () => { }, ); - it('calculate Iceberg πŸ₯¬ final price with normal vat rounded up', () => { - const product = new Product('Iceberg πŸ₯¬', 1.55, 0.15, TaxRate.Normal); - expect(product.finalPrice).toBe(2.17); - }); - - it('calculate Bread 🍞 final price with first necessity vat rounded up', () => { - const product = new Product('Bread 🍞', 0.71, 0.12, TaxRate.FirstNecessity); - expect(product.finalPrice).toBe(0.89); - }); + test.each([ + { + name: 'Iceberg πŸ₯¬', + cost: 1.55, + revenueMargin: 0.15, + taxRate: TaxRate.Normal, + expected: 2.17, + }, + { + name: 'Tomato πŸ…', + cost: 0.52, + revenueMargin: 0.15, + taxRate: TaxRate.Normal, + expected: 0.73, + }, + { + name: 'Chicken πŸ—', + cost: 1.34, + revenueMargin: 0.12, + taxRate: TaxRate.Normal, + expected: 1.83, + }, + { + name: 'Bread 🍞', + cost: 0.71, + revenueMargin: 0.12, + taxRate: TaxRate.FirstNecessity, + expected: 0.89, + }, + { + name: 'Corn 🌽', + cost: 1.21, + revenueMargin: 0.12, + taxRate: TaxRate.FirstNecessity, + expected: 1.5, + }, + ])( + 'calculate final price unit for $name to be $expected for vat rate @ $taxRate', + ({ name, cost, revenueMargin, taxRate, expected }) => { + const product = new Product(name, cost, revenueMargin, taxRate); + expect(product.finalPrice).toBe(expected); + }, + ); }); From 18b4da69434d79d236d8693b3206e2a8ec140bb0 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Sun, 4 May 2025 14:24:21 +0100 Subject: [PATCH 10/29] Iteration2: Create failing test for cart item --- test-pnpm/shopping-cart-kata/cartitem.should.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 test-pnpm/shopping-cart-kata/cartitem.should.test.ts diff --git a/test-pnpm/shopping-cart-kata/cartitem.should.test.ts b/test-pnpm/shopping-cart-kata/cartitem.should.test.ts new file mode 100644 index 0000000..6d1fb67 --- /dev/null +++ b/test-pnpm/shopping-cart-kata/cartitem.should.test.ts @@ -0,0 +1,12 @@ +import { Product } from './product'; +import { TaxRate } from './TaxRate'; + +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); + const expected = 2.17; + + expect(cartItem.LineTotal).toBe(expected); + }); +}); From ebce475171f0e938ae1834e88bdd4b1e89b60e14 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Sun, 4 May 2025 14:34:58 +0100 Subject: [PATCH 11/29] Iteration2: Make test pass for single item --- test-pnpm/shopping-cart-kata/cartItem.ts | 17 +++++++++++++++++ .../shopping-cart-kata/cartitem.should.test.ts | 1 + 2 files changed, 18 insertions(+) create mode 100644 test-pnpm/shopping-cart-kata/cartItem.ts diff --git a/test-pnpm/shopping-cart-kata/cartItem.ts b/test-pnpm/shopping-cart-kata/cartItem.ts new file mode 100644 index 0000000..cbf22e8 --- /dev/null +++ b/test-pnpm/shopping-cart-kata/cartItem.ts @@ -0,0 +1,17 @@ +import { Product } from './product'; + +export class CartItem { + constructor( + public product: Product, + public quantity: number, + ) {} + + get LineTotal(): number { + const price = this.product.finalPrice * this.quantity; + return this.roundUp(price); + } + + private roundUp(value: number): number { + return Math.ceil(value * 100) / 100; + } +} diff --git a/test-pnpm/shopping-cart-kata/cartitem.should.test.ts b/test-pnpm/shopping-cart-kata/cartitem.should.test.ts index 6d1fb67..0ecb5cb 100644 --- a/test-pnpm/shopping-cart-kata/cartitem.should.test.ts +++ b/test-pnpm/shopping-cart-kata/cartitem.should.test.ts @@ -1,3 +1,4 @@ +import { CartItem } from './cartItem'; import { Product } from './product'; import { TaxRate } from './TaxRate'; From 3046c362231db77d8d00e8eb3ce180021caed818 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Sun, 4 May 2025 14:37:12 +0100 Subject: [PATCH 12/29] Iteration2: Make test pass for multiple items --- test-pnpm/shopping-cart-kata/cartitem.should.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test-pnpm/shopping-cart-kata/cartitem.should.test.ts b/test-pnpm/shopping-cart-kata/cartitem.should.test.ts index 0ecb5cb..e7a58bd 100644 --- a/test-pnpm/shopping-cart-kata/cartitem.should.test.ts +++ b/test-pnpm/shopping-cart-kata/cartitem.should.test.ts @@ -4,10 +4,16 @@ import { TaxRate } from './TaxRate'; 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); - const expected = 2.17; - expect(cartItem.LineTotal).toBe(expected); + 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); }); }); From 113132cccee2137d52d54615b71cc7f827068903 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Sun, 4 May 2025 23:26:36 +0100 Subject: [PATCH 13/29] Iteration2: Minor refactor - Duplication removal Filename taxRate.ts --- test-pnpm/shopping-cart-kata/cartItem.ts | 7 ++----- test-pnpm/shopping-cart-kata/cartitem.should.test.ts | 2 +- test-pnpm/shopping-cart-kata/product.should.test.ts | 2 +- test-pnpm/shopping-cart-kata/product.ts | 10 ++++------ .../shopping-cart-kata/{TaxRate.ts => taxRates.ts} | 0 test-pnpm/shopping-cart-kata/utility.ts | 3 +++ 6 files changed, 11 insertions(+), 13 deletions(-) rename test-pnpm/shopping-cart-kata/{TaxRate.ts => taxRates.ts} (100%) create mode 100644 test-pnpm/shopping-cart-kata/utility.ts diff --git a/test-pnpm/shopping-cart-kata/cartItem.ts b/test-pnpm/shopping-cart-kata/cartItem.ts index cbf22e8..c6a1588 100644 --- a/test-pnpm/shopping-cart-kata/cartItem.ts +++ b/test-pnpm/shopping-cart-kata/cartItem.ts @@ -1,4 +1,5 @@ import { Product } from './product'; +import { roundUp } from './utility'; export class CartItem { constructor( @@ -8,10 +9,6 @@ export class CartItem { get LineTotal(): number { const price = this.product.finalPrice * this.quantity; - return this.roundUp(price); - } - - private roundUp(value: number): number { - return Math.ceil(value * 100) / 100; + return roundUp(price); } } diff --git a/test-pnpm/shopping-cart-kata/cartitem.should.test.ts b/test-pnpm/shopping-cart-kata/cartitem.should.test.ts index e7a58bd..8cce6fb 100644 --- a/test-pnpm/shopping-cart-kata/cartitem.should.test.ts +++ b/test-pnpm/shopping-cart-kata/cartitem.should.test.ts @@ -1,6 +1,6 @@ import { CartItem } from './cartItem'; import { Product } from './product'; -import { TaxRate } from './TaxRate'; +import { TaxRate } from './taxRates'; describe('Adding Cart items should', () => { let product = new Product('Iceberg πŸ₯¬', 1.55, 0.15, TaxRate.Normal); diff --git a/test-pnpm/shopping-cart-kata/product.should.test.ts b/test-pnpm/shopping-cart-kata/product.should.test.ts index ea743e8..294c94f 100644 --- a/test-pnpm/shopping-cart-kata/product.should.test.ts +++ b/test-pnpm/shopping-cart-kata/product.should.test.ts @@ -1,5 +1,5 @@ import { Product } from './product'; -import { TaxRate } from './TaxRate'; +import { TaxRate } from './taxRates'; describe('Product should', () => { test.each([ diff --git a/test-pnpm/shopping-cart-kata/product.ts b/test-pnpm/shopping-cart-kata/product.ts index 719525f..4884bbe 100644 --- a/test-pnpm/shopping-cart-kata/product.ts +++ b/test-pnpm/shopping-cart-kata/product.ts @@ -1,3 +1,5 @@ +import { roundUp } from './utility'; + export class Product { constructor( public name: string, @@ -8,15 +10,11 @@ export class Product { get pricePerUnit(): number { const price = this.cost * (1 + this.revenueMargin); - return this.roundUp(price); + return roundUp(price); } get finalPrice(): number { const price = this.pricePerUnit * (1 + this.taxRate); - return this.roundUp(price); - } - - private roundUp(value: number): number { - return Math.ceil(value * 100) / 100; + return roundUp(price); } } diff --git a/test-pnpm/shopping-cart-kata/TaxRate.ts b/test-pnpm/shopping-cart-kata/taxRates.ts similarity index 100% rename from test-pnpm/shopping-cart-kata/TaxRate.ts rename to test-pnpm/shopping-cart-kata/taxRates.ts diff --git a/test-pnpm/shopping-cart-kata/utility.ts b/test-pnpm/shopping-cart-kata/utility.ts new file mode 100644 index 0000000..9a5c375 --- /dev/null +++ b/test-pnpm/shopping-cart-kata/utility.ts @@ -0,0 +1,3 @@ +export function roundUp(value: number): number { + return Math.ceil(value * 100) / 100; +} From 8ca7cbf76b46afa09e721918c6177dcabbefd130 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Mon, 5 May 2025 00:23:34 +0100 Subject: [PATCH 14/29] Iteration3: Failing test for creating a discount --- test-pnpm/shopping-cart-kata/discount.should.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 test-pnpm/shopping-cart-kata/discount.should.test.ts diff --git a/test-pnpm/shopping-cart-kata/discount.should.test.ts b/test-pnpm/shopping-cart-kata/discount.should.test.ts new file mode 100644 index 0000000..d27b95c --- /dev/null +++ b/test-pnpm/shopping-cart-kata/discount.should.test.ts @@ -0,0 +1,7 @@ +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); + }); +}); From 6f2d4af984ae7b4b98e00b2c26c76d6793334212 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Mon, 5 May 2025 00:34:41 +0100 Subject: [PATCH 15/29] Iteration3: Make simple promo pass --- .../shopping-cart-kata/discount.should.test.ts | 3 +++ test-pnpm/shopping-cart-kata/discount.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 test-pnpm/shopping-cart-kata/discount.ts diff --git a/test-pnpm/shopping-cart-kata/discount.should.test.ts b/test-pnpm/shopping-cart-kata/discount.should.test.ts index d27b95c..d9ba246 100644 --- a/test-pnpm/shopping-cart-kata/discount.should.test.ts +++ b/test-pnpm/shopping-cart-kata/discount.should.test.ts @@ -1,6 +1,9 @@ +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); }); diff --git a/test-pnpm/shopping-cart-kata/discount.ts b/test-pnpm/shopping-cart-kata/discount.ts new file mode 100644 index 0000000..12ce93a --- /dev/null +++ b/test-pnpm/shopping-cart-kata/discount.ts @@ -0,0 +1,17 @@ +export class Discount { + readonly code: string; + readonly percentage: number; + + private constructor(code: string, percentage: number) { + this.code = code; + this.percentage = percentage; + } + + static fromCode(code: string): Discount | null { + const map: Record = { + PROMO_5: 0.05, + }; + const value = map[code]; + return new Discount(code, value); + } +} From d2edc1524ac627feed6a3de04f522b123d741b38 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Mon, 5 May 2025 00:35:52 +0100 Subject: [PATCH 16/29] Iteration3: Failing test for unknown promocode --- test-pnpm/shopping-cart-kata/discount.should.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test-pnpm/shopping-cart-kata/discount.should.test.ts b/test-pnpm/shopping-cart-kata/discount.should.test.ts index d9ba246..4f8de15 100644 --- a/test-pnpm/shopping-cart-kata/discount.should.test.ts +++ b/test-pnpm/shopping-cart-kata/discount.should.test.ts @@ -7,4 +7,9 @@ describe('Discount should', () => { 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(); + }); }); From f4648270d2abd05b523c47a1707f6f3d96ccc641 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Mon, 5 May 2025 00:43:32 +0100 Subject: [PATCH 17/29] Iteration3: Fix null discount for unknown discount code --- test-pnpm/shopping-cart-kata/discount.should.test.ts | 1 + test-pnpm/shopping-cart-kata/discount.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/test-pnpm/shopping-cart-kata/discount.should.test.ts b/test-pnpm/shopping-cart-kata/discount.should.test.ts index 4f8de15..2437ee6 100644 --- a/test-pnpm/shopping-cart-kata/discount.should.test.ts +++ b/test-pnpm/shopping-cart-kata/discount.should.test.ts @@ -10,6 +10,7 @@ describe('Discount should', () => { it('return null for an unknown code', () => { const discount = Discount.fromCode('INVALID'); + expect(discount).toBeNull(); }); }); diff --git a/test-pnpm/shopping-cart-kata/discount.ts b/test-pnpm/shopping-cart-kata/discount.ts index 12ce93a..c9e4bcc 100644 --- a/test-pnpm/shopping-cart-kata/discount.ts +++ b/test-pnpm/shopping-cart-kata/discount.ts @@ -12,6 +12,6 @@ export class Discount { PROMO_5: 0.05, }; const value = map[code]; - return new Discount(code, value); + return value ? new Discount(code, value) : null; } } From 294ab3204a01d807fff109fd54826de7b345048a Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Mon, 5 May 2025 00:46:24 +0100 Subject: [PATCH 18/29] Iteration3: Failing test for applying a 10% discount --- test-pnpm/shopping-cart-kata/discount.should.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test-pnpm/shopping-cart-kata/discount.should.test.ts b/test-pnpm/shopping-cart-kata/discount.should.test.ts index 2437ee6..54aea3b 100644 --- a/test-pnpm/shopping-cart-kata/discount.should.test.ts +++ b/test-pnpm/shopping-cart-kata/discount.should.test.ts @@ -13,4 +13,9 @@ describe('Discount should', () => { expect(discount).toBeNull(); }); + + it('alculate a discount from a price', () => { + const discount = Discount.fromCode('PROMO_10')!; + expect(discount.applyTo(100)).toBe(90); + }); }); From 75793831cf4c9010e804c068a578f85975999710 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Mon, 5 May 2025 00:55:00 +0100 Subject: [PATCH 19/29] Iteration3: Fix the method to apply discounts rounding up --- test-pnpm/shopping-cart-kata/discount.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test-pnpm/shopping-cart-kata/discount.ts b/test-pnpm/shopping-cart-kata/discount.ts index c9e4bcc..247c95c 100644 --- a/test-pnpm/shopping-cart-kata/discount.ts +++ b/test-pnpm/shopping-cart-kata/discount.ts @@ -1,3 +1,5 @@ +import { roundUp } from './utility'; + export class Discount { readonly code: string; readonly percentage: number; @@ -7,9 +9,16 @@ export class Discount { 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 = { PROMO_5: 0.05, + PROMO_10: 0.1, }; const value = map[code]; return value ? new Discount(code, value) : null; From e1c72da0d63dfe37ba8a477beba14ffcbf7d52bd Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 6 May 2025 09:58:46 +0100 Subject: [PATCH 20/29] Iteration4: Creted failing test for adding items to the shopping cart Add a shoping cart interface with the expected interface --- test-pnpm/shopping-cart-kata/README.md | 42 +++++++++---------- test-pnpm/shopping-cart-kata/shoppingCart.ts | 9 ++++ .../shoppingcart.should.test.ts | 40 ++++++++++++++++++ 3 files changed, 70 insertions(+), 21 deletions(-) create mode 100644 test-pnpm/shopping-cart-kata/shoppingCart.ts create mode 100644 test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts diff --git a/test-pnpm/shopping-cart-kata/README.md b/test-pnpm/shopping-cart-kata/README.md index 7ae1b1f..bb8d54a 100644 --- a/test-pnpm/shopping-cart-kata/README.md +++ b/test-pnpm/shopping-cart-kata/README.md @@ -11,20 +11,20 @@ We are building a shopping cart for an online grocery shop. The idea of this kat ### 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 € | +| **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% | +| **PROMO_5** | 5% | +| **PROMO_10** | 10% | ### Use cases @@ -149,23 +149,23 @@ You could change this API this is only for example purposes. **Approach 1 passing objects as arguments could be DTO** -```java -public interface ShoppingCart { - public void addItem(Product product); - public void deleteItem(Product product); - public void applyDiscount(Discount discount) - public void printShoppingCart(); +```javascript +export interface ShoppingCart { + addItem(product: Product): void; + deleteItem(product: Product): void; + applyDiscount(discount: Discount): void; + printShoppingCart(): void; } ``` **Approach 2 passing primitives as arguments** -```java -public interface ShoppingCart { - public void addItem(String productName); - public void deleteItem(String productName); - public void applyDiscount(Double discount) - public void printShoppingCart(); +```javascript +export interface ShoppingCart { + addItem(cartItem: CartItem): void; + deleteItem(cartItem: CartItem): void; + applyDiscount(discount: Discount): void; + printShoppingCart(): void; } Approach 3 passing primitives as arguments and returning a DTO diff --git a/test-pnpm/shopping-cart-kata/shoppingCart.ts b/test-pnpm/shopping-cart-kata/shoppingCart.ts new file mode 100644 index 0000000..8851549 --- /dev/null +++ b/test-pnpm/shopping-cart-kata/shoppingCart.ts @@ -0,0 +1,9 @@ +import { Discount } from './discount'; +import { Product } from './product'; + +export interface ShoppingCart { + addItem(product: Product): void; + deleteItem(product: Product): void; + applyDiscount(discount: Discount): void; + printShoppingCart(): void; +} diff --git a/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts b/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts new file mode 100644 index 0000000..5a35e5f --- /dev/null +++ b/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts @@ -0,0 +1,40 @@ +import { CartItem } from './cartItem'; +import { InMemoryLogger } from './logger'; +import { Product } from './product'; +import { InMemoryShoppingCart } from './shoppingCart'; +import { TaxRate } from './taxRates'; + +describe('As a customer', () => { + const iceberg = new Product('Iceberg πŸ₯¬', 1.55, 0.15, TaxRate.Normal); + const tomato = new Product('Tomato πŸ…', 0.52, 0.15, TaxRate.Normal); + const chicken = new Product('Chicken πŸ—', 1.34, 0.12, TaxRate.Normal); + const bread = new Product('Bread 🍞', 0.71, 0.12, TaxRate.FirstNecessity); + const corn = new Product('Corn 🌽', 1.21, 0.12, TaxRate.FirstNecessity); + + function addItemsToCart(cart: InMemoryShoppingCart) { + cart.addItem(new CartItem(iceberg, 3)); + cart.addItem(new CartItem(tomato, 1)); + cart.addItem(new CartItem(chicken, 1)); + cart.addItem(new CartItem(bread, 2)); + cart.addItem(new CartItem(corn, 1)); + } + + test('I want to add items to my shopping cart', () => { + const logger = new InMemoryLogger(); + const cart = new InMemoryShoppingCart(logger); + addItemsToCart(cart); + + const expectedOutput = [ + '--------------------------------------------', + '| Product name | Price with VAT | Quantity |', + '| ------------ | -------------- | -------- |', + '| Iceberg πŸ₯¬ | 2.17 € | 3 |', + '| Tomato πŸ… | 0.73 € | 1 |', + '| Chicken πŸ— | 1.83 € | 1 |', + '| Bread 🍞 | 0.89 € | 2 |', + '| Corn 🌽 | 1.50 € | 1 |', + ].join('\n'); + + expect(logger.print()).toContain(expectedOutput); + }); +}); From 53176b56963a51d20379afaaf036d7d5cd5425a7 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 6 May 2025 12:54:55 +0100 Subject: [PATCH 21/29] Iteration4: Make test pass with the header, line items --- test-pnpm/shopping-cart-kata/logger.ts | 16 +++++++++++ test-pnpm/shopping-cart-kata/printer.ts | 29 ++++++++++++++++++++ test-pnpm/shopping-cart-kata/shoppingCart.ts | 29 ++++++++++++++++++-- 3 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 test-pnpm/shopping-cart-kata/logger.ts create mode 100644 test-pnpm/shopping-cart-kata/printer.ts diff --git a/test-pnpm/shopping-cart-kata/logger.ts b/test-pnpm/shopping-cart-kata/logger.ts new file mode 100644 index 0000000..46007e7 --- /dev/null +++ b/test-pnpm/shopping-cart-kata/logger.ts @@ -0,0 +1,16 @@ +export interface Logger { + log(message: string): void; + print(): string; +} + +export class InMemoryLogger implements Logger { + private messages: string[] = []; + + log(message: string): void { + this.messages.push(message); + } + + print(): string { + return this.messages.join('\n'); + } +} diff --git a/test-pnpm/shopping-cart-kata/printer.ts b/test-pnpm/shopping-cart-kata/printer.ts new file mode 100644 index 0000000..521de22 --- /dev/null +++ b/test-pnpm/shopping-cart-kata/printer.ts @@ -0,0 +1,29 @@ +import { CartItem } from './cartItem'; + +export interface TablePrinter { + printHeader(): string; + printCartItem(item: CartItem): string; + printLineSeparator(): string; +} + +export class InMemoryTablePrinter implements TablePrinter { + printHeader(): string { + return [ + '--------------------------------------------', + '| Product name | Price with VAT | Quantity |', + '| ------------ | -------------- | -------- |', + ].join('\n'); + } + + printCartItem(item: CartItem): string { + const name = item.product.name.padEnd(12); + const priceWithVat = `${item.product.finalPrice.toFixed(2)} €`.padEnd(14); + const quantity = item.quantity.toString().padEnd(8); + + return `| ${name} | ${priceWithVat} | ${quantity} |`; + } + + printLineSeparator(): string { + return '--------------------------------------------'; + } +} diff --git a/test-pnpm/shopping-cart-kata/shoppingCart.ts b/test-pnpm/shopping-cart-kata/shoppingCart.ts index 8851549..2323a30 100644 --- a/test-pnpm/shopping-cart-kata/shoppingCart.ts +++ b/test-pnpm/shopping-cart-kata/shoppingCart.ts @@ -1,9 +1,32 @@ +import { CartItem } from './cartItem'; import { Discount } from './discount'; -import { Product } from './product'; +import { Logger } from './logger'; +import { InMemoryTablePrinter, TablePrinter } from './printer'; export interface ShoppingCart { - addItem(product: Product): void; - deleteItem(product: Product): void; + addItem(cartItem: CartItem): void; applyDiscount(discount: Discount): void; printShoppingCart(): void; } + +export class InMemoryShoppingCart implements ShoppingCart { + private items: CartItem[] = []; + private discounts: Discount[] = []; + private printer: TablePrinter = new InMemoryTablePrinter(); + + constructor(private logger: Logger) { + this.logger.log(this.printer.printHeader()); + } + + addItem(cartItem: CartItem): void { + this.items.push(cartItem); + this.logger.log(this.printer.printCartItem(cartItem)); + } + + applyDiscount(discount: Discount): void { + throw new Error('Method not implemented.'); + } + printShoppingCart(): void { + throw new Error('Method not implemented.'); + } +} From 6970df79cb74b801e83bffb0e0bbc5d0eca80e87 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 6 May 2025 16:41:22 +0100 Subject: [PATCH 22/29] Iteration5: Add promotion fail test scenarios --- test-pnpm/shopping-cart-kata/README.md | 2 +- .../shoppingcart.should.test.ts | 35 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/test-pnpm/shopping-cart-kata/README.md b/test-pnpm/shopping-cart-kata/README.md index bb8d54a..d761ee8 100644 --- a/test-pnpm/shopping-cart-kata/README.md +++ b/test-pnpm/shopping-cart-kata/README.md @@ -145,7 +145,7 @@ We are building a shopping cart for an online grocery shop. The idea of this kat ### Possible API for the ShoppingCart -You could change this API this is only for example purposes. +You could change this API, this is only for example purposes. **Approach 1 passing objects as arguments could be DTO** diff --git a/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts b/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts index 5a35e5f..829791d 100644 --- a/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts +++ b/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts @@ -1,4 +1,5 @@ import { CartItem } from './cartItem'; +import { Discount } from './discount'; import { InMemoryLogger } from './logger'; import { Product } from './product'; import { InMemoryShoppingCart } from './shoppingCart'; @@ -22,8 +23,6 @@ describe('As a customer', () => { test('I want to add items to my shopping cart', () => { const logger = new InMemoryLogger(); const cart = new InMemoryShoppingCart(logger); - addItemsToCart(cart); - const expectedOutput = [ '--------------------------------------------', '| Product name | Price with VAT | Quantity |', @@ -35,6 +34,38 @@ describe('As a customer', () => { '| Corn 🌽 | 1.50 € | 1 |', ].join('\n'); + addItemsToCart(cart); + + expect(logger.print()).toContain(expectedOutput); + }); + + test('I want to create a promotion section for no promotions', () => { + const logger = new InMemoryLogger(); + const cart = new InMemoryShoppingCart(logger); + addItemsToCart(cart); + const expectedOutput = [ + '|------------------------------------------|', + '| Promotion: |', + '--------------------------------------------', + ].join('\n'); + + cart.applyDiscount(null); + + expect(logger.print()).toContain(expectedOutput); + }); + + test('I want to create a promotion section for an actual promotion', () => { + const logger = new InMemoryLogger(); + const cart = new InMemoryShoppingCart(logger); + addItemsToCart(cart); + const expectedOutput = [ + '|------------------------------------------|', + '| Promotion: 10% off with code PROMO_10 |', + '--------------------------------------------', + ].join('\n'); + + cart.applyDiscount(Discount.fromCode('PROMO_10')); + expect(logger.print()).toContain(expectedOutput); }); }); From 7132291fac6e2d059c9492c17889313706e2d136 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 6 May 2025 16:41:44 +0100 Subject: [PATCH 23/29] Iteration5: Make tests pass --- test-pnpm/shopping-cart-kata/printer.ts | 16 ++++++++++++++++ test-pnpm/shopping-cart-kata/shoppingCart.ts | 6 ++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/test-pnpm/shopping-cart-kata/printer.ts b/test-pnpm/shopping-cart-kata/printer.ts index 521de22..b13722d 100644 --- a/test-pnpm/shopping-cart-kata/printer.ts +++ b/test-pnpm/shopping-cart-kata/printer.ts @@ -1,9 +1,12 @@ import { CartItem } from './cartItem'; +import { Discount } from './discount'; export interface TablePrinter { printHeader(): string; printCartItem(item: CartItem): string; + printPromotion(discount: Discount | null): string; printLineSeparator(): string; + printHeaderFooter(): string; } export class InMemoryTablePrinter implements TablePrinter { @@ -15,6 +18,10 @@ export class InMemoryTablePrinter implements TablePrinter { ].join('\n'); } + printHeaderFooter(): string { + return '|------------------------------------------|'; + } + printCartItem(item: CartItem): string { const name = item.product.name.padEnd(12); const priceWithVat = `${item.product.finalPrice.toFixed(2)} €`.padEnd(14); @@ -26,4 +33,13 @@ export class InMemoryTablePrinter implements TablePrinter { printLineSeparator(): string { return '--------------------------------------------'; } + + printPromotion(discount: Discount | null): string { + const promotionCode = discount?.code ?? ''; + const promotionDescription = + discount != null + ? `Promotion: ${discount?.percentage * 100}% off with code ${promotionCode}` + : 'Promotion:'; + return [this.printHeaderFooter(), `| ${promotionDescription.padEnd(40)} |`].join('\n'); + } } diff --git a/test-pnpm/shopping-cart-kata/shoppingCart.ts b/test-pnpm/shopping-cart-kata/shoppingCart.ts index 2323a30..a0e8ed7 100644 --- a/test-pnpm/shopping-cart-kata/shoppingCart.ts +++ b/test-pnpm/shopping-cart-kata/shoppingCart.ts @@ -23,8 +23,10 @@ export class InMemoryShoppingCart implements ShoppingCart { this.logger.log(this.printer.printCartItem(cartItem)); } - applyDiscount(discount: Discount): void { - throw new Error('Method not implemented.'); + applyDiscount(discount: Discount | null): void { + this.discounts.push(discount); + this.logger.log(this.printer.printPromotion(discount)); + this.logger.log(this.printer.printLineSeparator()); } printShoppingCart(): void { throw new Error('Method not implemented.'); From cfb23b4aea57c612461e95756a2a66b6513b57fc Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 6 May 2025 17:36:15 +0100 Subject: [PATCH 24/29] Iteration5: Minor refactor to make sure printing order and simplicity maintained --- test-pnpm/shopping-cart-kata/README.md | 21 ------------ test-pnpm/shopping-cart-kata/logger.ts | 11 +++++- test-pnpm/shopping-cart-kata/shoppingCart.ts | 34 ++++++++++++++----- .../shoppingcart.should.test.ts | 4 ++- 4 files changed, 39 insertions(+), 31 deletions(-) diff --git a/test-pnpm/shopping-cart-kata/README.md b/test-pnpm/shopping-cart-kata/README.md index d761ee8..b95ebc0 100644 --- a/test-pnpm/shopping-cart-kata/README.md +++ b/test-pnpm/shopping-cart-kata/README.md @@ -145,21 +145,8 @@ We are building a shopping cart for an online grocery shop. The idea of this kat ### Possible API for the ShoppingCart -You could change this API, this is only for example purposes. - **Approach 1 passing objects as arguments could be DTO** -```javascript -export interface ShoppingCart { - addItem(product: Product): void; - deleteItem(product: Product): void; - applyDiscount(discount: Discount): void; - printShoppingCart(): void; -} -``` - -**Approach 2 passing primitives as arguments** - ```javascript export interface ShoppingCart { addItem(cartItem: CartItem): void; @@ -167,14 +154,6 @@ export interface ShoppingCart { applyDiscount(discount: Discount): void; printShoppingCart(): void; } - -Approach 3 passing primitives as arguments and returning a DTO -public interface ShoppingCart { - public void addItem(String productName); - public void deleteItem(String productName); - public void applyDiscount(Double discount) - public ShoppingCartList getShoppingCart(); -} ``` ### Summary of steps diff --git a/test-pnpm/shopping-cart-kata/logger.ts b/test-pnpm/shopping-cart-kata/logger.ts index 46007e7..c6985b2 100644 --- a/test-pnpm/shopping-cart-kata/logger.ts +++ b/test-pnpm/shopping-cart-kata/logger.ts @@ -1,10 +1,19 @@ export interface Logger { log(message: string): void; print(): string; + clear(): void; } export class InMemoryLogger implements Logger { - private messages: string[] = []; + private messages: string[]; + + constructor() { + this.clear(); + } + + clear(): void { + this.messages = []; + } log(message: string): void { this.messages.push(message); diff --git a/test-pnpm/shopping-cart-kata/shoppingCart.ts b/test-pnpm/shopping-cart-kata/shoppingCart.ts index a0e8ed7..c75a7ac 100644 --- a/test-pnpm/shopping-cart-kata/shoppingCart.ts +++ b/test-pnpm/shopping-cart-kata/shoppingCart.ts @@ -14,21 +14,39 @@ export class InMemoryShoppingCart implements ShoppingCart { private discounts: Discount[] = []; private printer: TablePrinter = new InMemoryTablePrinter(); - constructor(private logger: Logger) { - this.logger.log(this.printer.printHeader()); - } + constructor(private logger: Logger) {} addItem(cartItem: CartItem): void { this.items.push(cartItem); - this.logger.log(this.printer.printCartItem(cartItem)); } applyDiscount(discount: Discount | null): void { - this.discounts.push(discount); - this.logger.log(this.printer.printPromotion(discount)); - this.logger.log(this.printer.printLineSeparator()); + if (discount) { + this.discounts.push(discount); + } } + printShoppingCart(): void { - throw new Error('Method not implemented.'); + this.logger.clear(); + this.printCartItems(); + this.printDiscounts(); + } + + private printCartItems(): void { + this.logger.log(this.printer.printHeader()); + this.items.forEach((cartItem) => { + this.logger.log(this.printer.printCartItem(cartItem)); + }); + } + + private printDiscounts(): void { + if (this.discounts.length > 0) { + this.discounts.forEach((discount) => { + this.logger.log(this.printer.printPromotion(discount)); + }); + } else { + this.logger.log(this.printer.printPromotion(null)); + } + this.logger.log(this.printer.printLineSeparator()); } } diff --git a/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts b/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts index 829791d..5e91b35 100644 --- a/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts +++ b/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts @@ -36,6 +36,7 @@ describe('As a customer', () => { addItemsToCart(cart); + cart.printShoppingCart(); expect(logger.print()).toContain(expectedOutput); }); @@ -49,7 +50,7 @@ describe('As a customer', () => { '--------------------------------------------', ].join('\n'); - cart.applyDiscount(null); + cart.printShoppingCart(); expect(logger.print()).toContain(expectedOutput); }); @@ -65,6 +66,7 @@ describe('As a customer', () => { ].join('\n'); cart.applyDiscount(Discount.fromCode('PROMO_10')); + cart.printShoppingCart(); expect(logger.print()).toContain(expectedOutput); }); From b4d9d0d6f7da72fc04ba29ce8dec0a948442b317 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 6 May 2025 17:37:02 +0100 Subject: [PATCH 25/29] Iteration6: Creat failing test for scenario --- .../shoppingcart.should.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts b/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts index 5e91b35..7c5e947 100644 --- a/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts +++ b/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts @@ -70,4 +70,21 @@ describe('As a customer', () => { expect(logger.print()).toContain(expectedOutput); }); + + test('I want a product count with final price', () => { + const logger = new InMemoryLogger(); + const cart = new InMemoryShoppingCart(logger); + addItemsToCart(cart); + const expectedOutput = [ + '|------------------------------------------|', + '| Total products: 8 |', + '| Total price: 11.71 € |', + '--------------------------------------------', + ].join('\n'); + cart.applyDiscount(Discount.fromCode('PROMO_5')); + + cart.printShoppingCart(); + + expect(logger.print()).toContain(expectedOutput); + }); }); From 42275c24a24f2d79d60d4a0f5a4e3081fae1e486 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 6 May 2025 18:27:48 +0100 Subject: [PATCH 26/29] Iteration6: Make test pass --- test-pnpm/shopping-cart-kata/printer.ts | 13 +++++++++++++ test-pnpm/shopping-cart-kata/shoppingCart.ts | 7 +++++++ .../shopping-cart-kata/shoppingcart.should.test.ts | 4 ++-- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/test-pnpm/shopping-cart-kata/printer.ts b/test-pnpm/shopping-cart-kata/printer.ts index b13722d..2b3bf15 100644 --- a/test-pnpm/shopping-cart-kata/printer.ts +++ b/test-pnpm/shopping-cart-kata/printer.ts @@ -2,6 +2,8 @@ import { CartItem } from './cartItem'; import { Discount } from './discount'; export interface TablePrinter { + printTotalPrice(items: CartItem[]): string; + printProductCount(items: CartItem[]): string; printHeader(): string; printCartItem(item: CartItem): string; printPromotion(discount: Discount | null): string; @@ -42,4 +44,15 @@ export class InMemoryTablePrinter implements TablePrinter { : 'Promotion:'; return [this.printHeaderFooter(), `| ${promotionDescription.padEnd(40)} |`].join('\n'); } + + printProductCount(items: CartItem[]): string { + const totalItems = items.reduce((total, item) => total + item.quantity, 0); + return `| Total products: ${totalItems.toString().padEnd(24)} |`; + } + + printTotalPrice(items: CartItem[]): string { + const totalPrice = items.reduce((total, item) => total + item.LineTotal, 0); + const description = `Total price: ${totalPrice.toFixed(2)} €`; + return `| ${description.padEnd(40)} |`; + } } diff --git a/test-pnpm/shopping-cart-kata/shoppingCart.ts b/test-pnpm/shopping-cart-kata/shoppingCart.ts index c75a7ac..5ef37fe 100644 --- a/test-pnpm/shopping-cart-kata/shoppingCart.ts +++ b/test-pnpm/shopping-cart-kata/shoppingCart.ts @@ -30,6 +30,13 @@ export class InMemoryShoppingCart implements ShoppingCart { this.logger.clear(); this.printCartItems(); this.printDiscounts(); + this.printTotals(); + } + + private printTotals() { + this.logger.log(this.printer.printProductCount(this.items)); + this.logger.log(this.printer.printTotalPrice(this.items)); + this.logger.log(this.printer.printLineSeparator()); } private printCartItems(): void { diff --git a/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts b/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts index 7c5e947..bce67ed 100644 --- a/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts +++ b/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts @@ -76,9 +76,9 @@ describe('As a customer', () => { const cart = new InMemoryShoppingCart(logger); addItemsToCart(cart); const expectedOutput = [ - '|------------------------------------------|', + '--------------------------------------------', '| Total products: 8 |', - '| Total price: 11.71 € |', + '| Total price: 12.35 € |', '--------------------------------------------', ].join('\n'); cart.applyDiscount(Discount.fromCode('PROMO_5')); From ed72983bd219afc81d0c941317ffdf346cd15442 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 6 May 2025 19:11:20 +0100 Subject: [PATCH 27/29] Iteration6: Refactored final passing tests with full story and skipped lead up --- test-pnpm/shopping-cart-kata/printer.ts | 7 ++- test-pnpm/shopping-cart-kata/shoppingCart.ts | 2 +- .../shoppingcart.should.test.ts | 55 +++++++++++++++++-- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/test-pnpm/shopping-cart-kata/printer.ts b/test-pnpm/shopping-cart-kata/printer.ts index 2b3bf15..582336f 100644 --- a/test-pnpm/shopping-cart-kata/printer.ts +++ b/test-pnpm/shopping-cart-kata/printer.ts @@ -2,7 +2,7 @@ import { CartItem } from './cartItem'; import { Discount } from './discount'; export interface TablePrinter { - printTotalPrice(items: CartItem[]): string; + printTotalPrice(items: CartItem[], discount: Discount | null): string; printProductCount(items: CartItem[]): string; printHeader(): string; printCartItem(item: CartItem): string; @@ -50,9 +50,10 @@ export class InMemoryTablePrinter implements TablePrinter { return `| Total products: ${totalItems.toString().padEnd(24)} |`; } - printTotalPrice(items: CartItem[]): string { + printTotalPrice(items: CartItem[], discount: Discount | null): string { const totalPrice = items.reduce((total, item) => total + item.LineTotal, 0); - const description = `Total price: ${totalPrice.toFixed(2)} €`; + const discountedAmount = discount ? discount.applyTo(totalPrice) : totalPrice; + const description = `Total price: ${discountedAmount.toFixed(2)} €`; return `| ${description.padEnd(40)} |`; } } diff --git a/test-pnpm/shopping-cart-kata/shoppingCart.ts b/test-pnpm/shopping-cart-kata/shoppingCart.ts index 5ef37fe..460f4d0 100644 --- a/test-pnpm/shopping-cart-kata/shoppingCart.ts +++ b/test-pnpm/shopping-cart-kata/shoppingCart.ts @@ -35,7 +35,7 @@ export class InMemoryShoppingCart implements ShoppingCart { private printTotals() { this.logger.log(this.printer.printProductCount(this.items)); - this.logger.log(this.printer.printTotalPrice(this.items)); + this.logger.log(this.printer.printTotalPrice(this.items, this.discounts[0] ?? null)); this.logger.log(this.printer.printLineSeparator()); } diff --git a/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts b/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts index bce67ed..256df45 100644 --- a/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts +++ b/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts @@ -20,7 +20,7 @@ describe('As a customer', () => { cart.addItem(new CartItem(corn, 1)); } - test('I want to add items to my shopping cart', () => { + test.skip('I want to add items to my shopping cart', () => { const logger = new InMemoryLogger(); const cart = new InMemoryShoppingCart(logger); const expectedOutput = [ @@ -40,7 +40,7 @@ describe('As a customer', () => { expect(logger.print()).toContain(expectedOutput); }); - test('I want to create a promotion section for no promotions', () => { + test.skip('I want to create a promotion section for no promotions', () => { const logger = new InMemoryLogger(); const cart = new InMemoryShoppingCart(logger); addItemsToCart(cart); @@ -55,7 +55,7 @@ describe('As a customer', () => { expect(logger.print()).toContain(expectedOutput); }); - test('I want to create a promotion section for an actual promotion', () => { + test.skip('I want to create a promotion section for an actual promotion', () => { const logger = new InMemoryLogger(); const cart = new InMemoryShoppingCart(logger); addItemsToCart(cart); @@ -78,7 +78,7 @@ describe('As a customer', () => { const expectedOutput = [ '--------------------------------------------', '| Total products: 8 |', - '| Total price: 12.35 € |', + '| Total price: 11.74 € |', '--------------------------------------------', ].join('\n'); cart.applyDiscount(Discount.fromCode('PROMO_5')); @@ -87,4 +87,51 @@ describe('As a customer', () => { expect(logger.print()).toContain(expectedOutput); }); + + test('I want to print an empty cart', () => { + const logger = new InMemoryLogger(); + const cart = new InMemoryShoppingCart(logger); + const expectedOutput = [ + '--------------------------------------------', + '| Product name | Price with VAT | Quantity |', + '| ------------ | -------------- | -------- |', + '|------------------------------------------|', + '| Promotion: |', + '--------------------------------------------', + '| Total products: 0 |', + '| Total price: 0.00 € |', + '--------------------------------------------', + ].join('\n'); + + cart.printShoppingCart(); + + expect(logger.print()).toBe(expectedOutput); + }); + + test('I want to print a full discounted list', () => { + const logger = new InMemoryLogger(); + const cart = new InMemoryShoppingCart(logger); + addItemsToCart(cart); + cart.applyDiscount(Discount.fromCode('PROMO_10')); + const expectedOutput = [ + '--------------------------------------------', + '| Product name | Price with VAT | Quantity |', + '| ------------ | -------------- | -------- |', + '| Iceberg πŸ₯¬ | 2.17 € | 3 |', + '| Tomato πŸ… | 0.73 € | 1 |', + '| Chicken πŸ— | 1.83 € | 1 |', + '| Bread 🍞 | 0.89 € | 2 |', + '| Corn 🌽 | 1.50 € | 1 |', + '|------------------------------------------|', + '| Promotion: 10% off with code PROMO_10 |', + '--------------------------------------------', + '| Total products: 8 |', + '| Total price: 11.12 € |', + '--------------------------------------------', + ].join('\n'); + + cart.printShoppingCart(); + + expect(logger.print()).toBe(expectedOutput); + }); }); From 8f87fbc27e977a77328db46ec0aad11b0fa6d5a3 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 6 May 2025 20:19:31 +0100 Subject: [PATCH 28/29] Iteration6: Refactor magic strings and printer patterns Fix small typo in test --- test-pnpm/shopping-cart-kata/README.md | 4 ++ test-pnpm/shopping-cart-kata/constants.ts | 15 +++++ .../discount.should.test.ts | 2 +- test-pnpm/shopping-cart-kata/printer.ts | 67 +++++++++++++------ test-pnpm/shopping-cart-kata/shoppingCart.ts | 4 +- 5 files changed, 68 insertions(+), 24 deletions(-) create mode 100644 test-pnpm/shopping-cart-kata/constants.ts diff --git a/test-pnpm/shopping-cart-kata/README.md b/test-pnpm/shopping-cart-kata/README.md index b95ebc0..b28c9a0 100644 --- a/test-pnpm/shopping-cart-kata/README.md +++ b/test-pnpm/shopping-cart-kata/README.md @@ -199,3 +199,7 @@ You could start Outside-In **only** if: - 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 + +Thnanks Emmanuael Valverde, for your cool takle 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. diff --git a/test-pnpm/shopping-cart-kata/constants.ts b/test-pnpm/shopping-cart-kata/constants.ts new file mode 100644 index 0000000..2877e3f --- /dev/null +++ b/test-pnpm/shopping-cart-kata/constants.ts @@ -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: '|', + }, +}; diff --git a/test-pnpm/shopping-cart-kata/discount.should.test.ts b/test-pnpm/shopping-cart-kata/discount.should.test.ts index 54aea3b..1e4e299 100644 --- a/test-pnpm/shopping-cart-kata/discount.should.test.ts +++ b/test-pnpm/shopping-cart-kata/discount.should.test.ts @@ -14,7 +14,7 @@ describe('Discount should', () => { expect(discount).toBeNull(); }); - it('alculate a discount from a price', () => { + it('calculate a discount from a price', () => { const discount = Discount.fromCode('PROMO_10')!; expect(discount.applyTo(100)).toBe(90); }); diff --git a/test-pnpm/shopping-cart-kata/printer.ts b/test-pnpm/shopping-cart-kata/printer.ts index 582336f..93d7f54 100644 --- a/test-pnpm/shopping-cart-kata/printer.ts +++ b/test-pnpm/shopping-cart-kata/printer.ts @@ -1,4 +1,5 @@ import { CartItem } from './cartItem'; +import { TABLE_CONSTANTS } from './constants'; import { Discount } from './discount'; export interface TablePrinter { @@ -11,49 +12,73 @@ export interface TablePrinter { printHeaderFooter(): string; } -export class InMemoryTablePrinter implements TablePrinter { +export class DefaultTablePrinter implements TablePrinter { printHeader(): string { return [ - '--------------------------------------------', - '| Product name | Price with VAT | Quantity |', - '| ------------ | -------------- | -------- |', + this.printLineSeparator(), + `${TABLE_CONSTANTS.SEPARATORS.VERTICAL} Product name ${TABLE_CONSTANTS.SEPARATORS.VERTICAL} Price with VAT ${TABLE_CONSTANTS.SEPARATORS.VERTICAL} Quantity ${TABLE_CONSTANTS.SEPARATORS.VERTICAL}`, + `${TABLE_CONSTANTS.SEPARATORS.VERTICAL} ${'-'.repeat(TABLE_CONSTANTS.COLUMN_WIDTHS.NAME)} ${TABLE_CONSTANTS.SEPARATORS.VERTICAL} ${'-'.repeat(TABLE_CONSTANTS.COLUMN_WIDTHS.PRICE)} ${TABLE_CONSTANTS.SEPARATORS.VERTICAL} ${'-'.repeat(TABLE_CONSTANTS.COLUMN_WIDTHS.QUANTITY)} ${TABLE_CONSTANTS.SEPARATORS.VERTICAL}`, ].join('\n'); } printHeaderFooter(): string { - return '|------------------------------------------|'; + return `${TABLE_CONSTANTS.SEPARATORS.VERTICAL}${'-'.repeat(TABLE_CONSTANTS.LINE_WIDTH - 2)}${TABLE_CONSTANTS.SEPARATORS.VERTICAL}`; } printCartItem(item: CartItem): string { - const name = item.product.name.padEnd(12); - const priceWithVat = `${item.product.finalPrice.toFixed(2)} €`.padEnd(14); - const quantity = item.quantity.toString().padEnd(8); + const name = item.product.name.padEnd(TABLE_CONSTANTS.COLUMN_WIDTHS.NAME); + const priceWithVat = + `${this.formatPrice(item.product.finalPrice)} ${TABLE_CONSTANTS.CURRENCY}`.padEnd( + TABLE_CONSTANTS.COLUMN_WIDTHS.PRICE, + ); + const quantity = item.quantity.toString().padEnd(TABLE_CONSTANTS.COLUMN_WIDTHS.QUANTITY); - return `| ${name} | ${priceWithVat} | ${quantity} |`; + return `${TABLE_CONSTANTS.SEPARATORS.VERTICAL} ${name} ${TABLE_CONSTANTS.SEPARATORS.VERTICAL} ${priceWithVat} ${TABLE_CONSTANTS.SEPARATORS.VERTICAL} ${quantity} ${TABLE_CONSTANTS.SEPARATORS.VERTICAL}`; } printLineSeparator(): string { - return '--------------------------------------------'; + return TABLE_CONSTANTS.SEPARATORS.HORIZONTAL.repeat(TABLE_CONSTANTS.LINE_WIDTH); } printPromotion(discount: Discount | null): string { - const promotionCode = discount?.code ?? ''; - const promotionDescription = - discount != null - ? `Promotion: ${discount?.percentage * 100}% off with code ${promotionCode}` - : 'Promotion:'; - return [this.printHeaderFooter(), `| ${promotionDescription.padEnd(40)} |`].join('\n'); + const promotionDescription = this.formatPromotionDescription(discount); + return [ + this.printHeaderFooter(), + `${TABLE_CONSTANTS.SEPARATORS.VERTICAL} ${promotionDescription.padEnd(TABLE_CONSTANTS.PADDING_WIDTH)} ${TABLE_CONSTANTS.SEPARATORS.VERTICAL}`, + ].join('\n'); } printProductCount(items: CartItem[]): string { - const totalItems = items.reduce((total, item) => total + item.quantity, 0); - return `| Total products: ${totalItems.toString().padEnd(24)} |`; + const totalItems = this.calculateTotalItems(items); + return `${TABLE_CONSTANTS.SEPARATORS.VERTICAL} Total products: ${totalItems.toString().padEnd(24)} ${TABLE_CONSTANTS.SEPARATORS.VERTICAL}`; } printTotalPrice(items: CartItem[], discount: Discount | null): string { - const totalPrice = items.reduce((total, item) => total + item.LineTotal, 0); + const totalPrice = this.calculateTotalPrice(items); const discountedAmount = discount ? discount.applyTo(totalPrice) : totalPrice; - const description = `Total price: ${discountedAmount.toFixed(2)} €`; - return `| ${description.padEnd(40)} |`; + const description = `Total price: ${this.formatPrice(discountedAmount)} ${TABLE_CONSTANTS.CURRENCY}`; + + return `${TABLE_CONSTANTS.SEPARATORS.VERTICAL} ${description.padEnd(TABLE_CONSTANTS.PADDING_WIDTH)} ${TABLE_CONSTANTS.SEPARATORS.VERTICAL}`; + } + + private formatPromotionDescription(discount: Discount | null): string { + if (discount == null) { + return 'Promotion:'; + } + + const percentage = Math.round(discount.percentage * 100); + return `Promotion: ${percentage}% off with code ${discount.code}`; + } + + private calculateTotalItems(items: CartItem[]): number { + return items.reduce((total, item) => total + item.quantity, 0); + } + + private calculateTotalPrice(items: CartItem[]): number { + return items.reduce((total, item) => total + item.LineTotal, 0); + } + + private formatPrice(price: number): string { + return price.toFixed(2); } } diff --git a/test-pnpm/shopping-cart-kata/shoppingCart.ts b/test-pnpm/shopping-cart-kata/shoppingCart.ts index 460f4d0..e315f20 100644 --- a/test-pnpm/shopping-cart-kata/shoppingCart.ts +++ b/test-pnpm/shopping-cart-kata/shoppingCart.ts @@ -1,7 +1,7 @@ import { CartItem } from './cartItem'; import { Discount } from './discount'; import { Logger } from './logger'; -import { InMemoryTablePrinter, TablePrinter } from './printer'; +import { DefaultTablePrinter, TablePrinter } from './printer'; export interface ShoppingCart { addItem(cartItem: CartItem): void; @@ -12,7 +12,7 @@ export interface ShoppingCart { export class InMemoryShoppingCart implements ShoppingCart { private items: CartItem[] = []; private discounts: Discount[] = []; - private printer: TablePrinter = new InMemoryTablePrinter(); + private printer: TablePrinter = new DefaultTablePrinter(); constructor(private logger: Logger) {} From 3132415a64eab87068c2da8b42796783b6243ce5 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Mon, 12 May 2025 08:50:10 +0100 Subject: [PATCH 29/29] Add some future tasks to extend this and fix some typos --- test-pnpm/shopping-cart-kata/README.md | 44 +++++++++++++++----------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/test-pnpm/shopping-cart-kata/README.md b/test-pnpm/shopping-cart-kata/README.md index b28c9a0..f9c7aa7 100644 --- a/test-pnpm/shopping-cart-kata/README.md +++ b/test-pnpm/shopping-cart-kata/README.md @@ -11,13 +11,13 @@ We are building a shopping cart for an online grocery shop. The idea of this kat ### 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 € | +| **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 @@ -150,9 +150,12 @@ We are building a shopping cart for an online grocery shop. The idea of this kat ```javascript export interface ShoppingCart { addItem(cartItem: CartItem): void; - deleteItem(cartItem: CartItem): void; applyDiscount(discount: Discount): void; printShoppingCart(): void; + // TODO + removeItem(name: string): void; + updateItemQuantity(name: string, quantity: number): void; + getItems(): ReadonlyArray; } ``` @@ -162,15 +165,18 @@ export interface ShoppingCart { ##### 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/remove 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 | +| 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 @@ -191,7 +197,7 @@ export interface ShoppingCart { - `print()` or `toString()` as test-driving format rendering - Possibly inject or access product catalogue here -### When to Consider *Outside-In* +### When to Consider _Outside-In_ You could start Outside-In **only** if: @@ -202,4 +208,4 @@ But in this kata, that’s harder to justify because the logic in the "leaf" typ ### Summary -Thnanks Emmanuael Valverde, for your cool takle 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. +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.