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..f9c7aa7 --- /dev/null +++ b/test-pnpm/shopping-cart-kata/README.md @@ -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; +} +``` + +### 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. diff --git a/test-pnpm/shopping-cart-kata/cartItem.ts b/test-pnpm/shopping-cart-kata/cartItem.ts new file mode 100644 index 0000000..c6a1588 --- /dev/null +++ b/test-pnpm/shopping-cart-kata/cartItem.ts @@ -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); + } +} 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..8cce6fb --- /dev/null +++ b/test-pnpm/shopping-cart-kata/cartitem.should.test.ts @@ -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); + }); +}); 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 new file mode 100644 index 0000000..1e4e299 --- /dev/null +++ b/test-pnpm/shopping-cart-kata/discount.should.test.ts @@ -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); + }); +}); diff --git a/test-pnpm/shopping-cart-kata/discount.ts b/test-pnpm/shopping-cart-kata/discount.ts new file mode 100644 index 0000000..247c95c --- /dev/null +++ b/test-pnpm/shopping-cart-kata/discount.ts @@ -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 = { + PROMO_5: 0.05, + PROMO_10: 0.1, + }; + const value = map[code]; + return value ? new Discount(code, value) : null; + } +} diff --git a/test-pnpm/shopping-cart-kata/logger.ts b/test-pnpm/shopping-cart-kata/logger.ts new file mode 100644 index 0000000..c6985b2 --- /dev/null +++ b/test-pnpm/shopping-cart-kata/logger.ts @@ -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'); + } +} diff --git a/test-pnpm/shopping-cart-kata/printer.ts b/test-pnpm/shopping-cart-kata/printer.ts new file mode 100644 index 0000000..93d7f54 --- /dev/null +++ b/test-pnpm/shopping-cart-kata/printer.ts @@ -0,0 +1,84 @@ +import { CartItem } from './cartItem'; +import { TABLE_CONSTANTS } from './constants'; +import { Discount } from './discount'; + +export interface TablePrinter { + printTotalPrice(items: CartItem[], discount: Discount | null): string; + printProductCount(items: CartItem[]): string; + printHeader(): string; + printCartItem(item: CartItem): string; + printPromotion(discount: Discount | null): string; + printLineSeparator(): string; + printHeaderFooter(): string; +} + +export class DefaultTablePrinter implements TablePrinter { + printHeader(): string { + return [ + 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 `${TABLE_CONSTANTS.SEPARATORS.VERTICAL}${'-'.repeat(TABLE_CONSTANTS.LINE_WIDTH - 2)}${TABLE_CONSTANTS.SEPARATORS.VERTICAL}`; + } + + printCartItem(item: CartItem): string { + 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 `${TABLE_CONSTANTS.SEPARATORS.VERTICAL} ${name} ${TABLE_CONSTANTS.SEPARATORS.VERTICAL} ${priceWithVat} ${TABLE_CONSTANTS.SEPARATORS.VERTICAL} ${quantity} ${TABLE_CONSTANTS.SEPARATORS.VERTICAL}`; + } + + printLineSeparator(): string { + return TABLE_CONSTANTS.SEPARATORS.HORIZONTAL.repeat(TABLE_CONSTANTS.LINE_WIDTH); + } + + printPromotion(discount: Discount | null): string { + 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 = 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 = this.calculateTotalPrice(items); + const discountedAmount = discount ? discount.applyTo(totalPrice) : totalPrice; + 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/product.should.test.ts b/test-pnpm/shopping-cart-kata/product.should.test.ts new file mode 100644 index 0000000..294c94f --- /dev/null +++ b/test-pnpm/shopping-cart-kata/product.should.test.ts @@ -0,0 +1,87 @@ +import { Product } from './product'; +import { TaxRate } from './taxRates'; + +describe('Product should', () => { + test.each([ + { + name: 'Iceberg πŸ₯¬', + cost: 1.55, + revenueMargin: 0.15, + expected: 1.79, + }, + { + name: 'Tomato πŸ…', + 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', + ({ name, cost, revenueMargin, expected }) => { + const product = new Product(name, cost, revenueMargin); + expect(product.pricePerUnit).toBe(expected); + }, + ); + + 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); + }, + ); +}); diff --git a/test-pnpm/shopping-cart-kata/product.ts b/test-pnpm/shopping-cart-kata/product.ts new file mode 100644 index 0000000..4884bbe --- /dev/null +++ b/test-pnpm/shopping-cart-kata/product.ts @@ -0,0 +1,20 @@ +import { roundUp } from './utility'; + +export class Product { + constructor( + public name: string, + public cost: number, + public revenueMargin: number, + public taxRate: number = 0, + ) {} + + get pricePerUnit(): number { + const price = this.cost * (1 + this.revenueMargin); + return roundUp(price); + } + + get finalPrice(): number { + const price = this.pricePerUnit * (1 + this.taxRate); + return roundUp(price); + } +} diff --git a/test-pnpm/shopping-cart-kata/shoppingCart.ts b/test-pnpm/shopping-cart-kata/shoppingCart.ts new file mode 100644 index 0000000..e315f20 --- /dev/null +++ b/test-pnpm/shopping-cart-kata/shoppingCart.ts @@ -0,0 +1,59 @@ +import { CartItem } from './cartItem'; +import { Discount } from './discount'; +import { Logger } from './logger'; +import { DefaultTablePrinter, TablePrinter } from './printer'; + +export interface ShoppingCart { + 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 DefaultTablePrinter(); + + constructor(private logger: Logger) {} + + addItem(cartItem: CartItem): void { + this.items.push(cartItem); + } + + applyDiscount(discount: Discount | null): void { + if (discount) { + this.discounts.push(discount); + } + } + + printShoppingCart(): void { + 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.discounts[0] ?? null)); + this.logger.log(this.printer.printLineSeparator()); + } + + 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 new file mode 100644 index 0000000..256df45 --- /dev/null +++ b/test-pnpm/shopping-cart-kata/shoppingcart.should.test.ts @@ -0,0 +1,137 @@ +import { CartItem } from './cartItem'; +import { Discount } from './discount'; +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.skip('I want to add items to my shopping cart', () => { + const logger = new InMemoryLogger(); + const cart = new InMemoryShoppingCart(logger); + 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'); + + addItemsToCart(cart); + + cart.printShoppingCart(); + expect(logger.print()).toContain(expectedOutput); + }); + + test.skip('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.printShoppingCart(); + + expect(logger.print()).toContain(expectedOutput); + }); + + test.skip('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')); + cart.printShoppingCart(); + + 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.74 € |', + '--------------------------------------------', + ].join('\n'); + cart.applyDiscount(Discount.fromCode('PROMO_5')); + + cart.printShoppingCart(); + + 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); + }); +}); diff --git a/test-pnpm/shopping-cart-kata/taxRates.ts b/test-pnpm/shopping-cart-kata/taxRates.ts new file mode 100644 index 0000000..42f84f8 --- /dev/null +++ b/test-pnpm/shopping-cart-kata/taxRates.ts @@ -0,0 +1,4 @@ +export enum TaxRate { + Normal = 0.21, + FirstNecessity = 0.1, +} 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; +}