From 9a3c02b1d9d9bfffd7d668a8f32759e806805f78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:01:01 +0000 Subject: [PATCH 1/2] Initial plan From 569a90a1969be4e06bb2de6efaa3b686dbe4c11c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:08:50 +0000 Subject: [PATCH 2/2] Implement bundle discount feature with comprehensive tests Co-authored-by: nstubbe <20206435+nstubbe@users.noreply.github.com> --- typescript/src/model/Bundle.ts | 14 +++ typescript/src/model/BundleOffer.ts | 13 ++ typescript/src/model/ShoppingCart.ts | 39 ++++++ typescript/src/model/SpecialOfferType.ts | 2 +- typescript/src/model/Teller.ts | 10 ++ typescript/test/Supermarket.test.ts | 146 +++++++++++++++++++++++ 6 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 typescript/src/model/Bundle.ts create mode 100644 typescript/src/model/BundleOffer.ts diff --git a/typescript/src/model/Bundle.ts b/typescript/src/model/Bundle.ts new file mode 100644 index 0000000..bc900bb --- /dev/null +++ b/typescript/src/model/Bundle.ts @@ -0,0 +1,14 @@ +import {Product} from "./Product" + +export class Bundle { + constructor(public readonly products: Product[]) { + } + + containsProduct(product: Product): boolean { + return this.products.some(p => p.name === product.name); + } + + getProducts(): Product[] { + return [...this.products]; + } +} \ No newline at end of file diff --git a/typescript/src/model/BundleOffer.ts b/typescript/src/model/BundleOffer.ts new file mode 100644 index 0000000..6b09ab9 --- /dev/null +++ b/typescript/src/model/BundleOffer.ts @@ -0,0 +1,13 @@ +import {Bundle} from "./Bundle" +import {SpecialOfferType} from "./SpecialOfferType" + +export class BundleOffer { + public constructor(public readonly offerType: SpecialOfferType, + public readonly bundle: Bundle, + public readonly discountPercentage: number) { + } + + getBundle(): Bundle { + return this.bundle; + } +} \ No newline at end of file diff --git a/typescript/src/model/ShoppingCart.ts b/typescript/src/model/ShoppingCart.ts index ac090cc..d0b260b 100644 --- a/typescript/src/model/ShoppingCart.ts +++ b/typescript/src/model/ShoppingCart.ts @@ -6,6 +6,7 @@ import {Discount} from "./Discount" import {Receipt} from "./Receipt" import {Offer} from "./Offer" import {SpecialOfferType} from "./SpecialOfferType" +import {BundleOffer} from "./BundleOffer" type ProductQuantities = { [productName: string]: ProductQuantity } export type OffersByProduct = {[productName: string]: Offer}; @@ -88,4 +89,42 @@ export class ShoppingCart { } } + + handleBundleOffers(receipt: Receipt, bundleOffers: BundleOffer[], catalog: SupermarketCatalog): void { + for (const bundleOffer of bundleOffers) { + const bundle = bundleOffer.getBundle(); + const bundleProducts = bundle.getProducts(); + + // Calculate how many complete bundles we can make + let maxCompleteBundle = Number.MAX_SAFE_INTEGER; + + for (const bundleProduct of bundleProducts) { + const productQuantity = this._productQuantities[bundleProduct.name]; + if (!productQuantity) { + maxCompleteBundle = 0; + break; + } + maxCompleteBundle = Math.min(maxCompleteBundle, Math.floor(productQuantity.quantity)); + } + + if (maxCompleteBundle > 0) { + // Calculate discount for complete bundles + let bundleTotal = 0; + for (const bundleProduct of bundleProducts) { + const unitPrice = catalog.getUnitPrice(bundleProduct); + bundleTotal += unitPrice * maxCompleteBundle; + } + + const discountAmount = bundleTotal * (bundleOffer.discountPercentage / 100.0); + + // Create a discount with the first product in the bundle as representative + const discount = new Discount( + bundleProducts[0], + `Bundle discount (${bundleOffer.discountPercentage}% off)`, + discountAmount + ); + receipt.addDiscount(discount); + } + } + } } diff --git a/typescript/src/model/SpecialOfferType.ts b/typescript/src/model/SpecialOfferType.ts index 6dae8cf..1f1cfb5 100644 --- a/typescript/src/model/SpecialOfferType.ts +++ b/typescript/src/model/SpecialOfferType.ts @@ -1,4 +1,4 @@ export enum SpecialOfferType { - ThreeForTwo, TenPercentDiscount, TwoForAmount, FiveForAmount + ThreeForTwo, TenPercentDiscount, TwoForAmount, FiveForAmount, Bundle } diff --git a/typescript/src/model/Teller.ts b/typescript/src/model/Teller.ts index 97e626c..ca4baa9 100644 --- a/typescript/src/model/Teller.ts +++ b/typescript/src/model/Teller.ts @@ -4,10 +4,13 @@ import {Product} from "./Product" import {Receipt} from "./Receipt" import {Offer} from "./Offer" import {SpecialOfferType} from "./SpecialOfferType" +import {BundleOffer} from "./BundleOffer" +import {Bundle} from "./Bundle" export class Teller { private offers: OffersByProduct = {}; + private bundleOffers: BundleOffer[] = []; public constructor(private readonly catalog: SupermarketCatalog ) { } @@ -16,6 +19,12 @@ export class Teller { this.offers[product.name] = new Offer(offerType, product, argument); } + public addBundleOffer(products: Product[], discountPercentage: number = 10): void { + const bundle = new Bundle(products); + const bundleOffer = new BundleOffer(SpecialOfferType.Bundle, bundle, discountPercentage); + this.bundleOffers.push(bundleOffer); + } + public checksOutArticlesFrom(theCart: ShoppingCart): Receipt { const receipt = new Receipt(); const productQuantities = theCart.getItems(); @@ -27,6 +36,7 @@ export class Teller { receipt.addProduct(p, quantity, unitPrice, price); } theCart.handleOffers(receipt, this.offers, this.catalog); + theCart.handleBundleOffers(receipt, this.bundleOffers, this.catalog); return receipt; } diff --git a/typescript/test/Supermarket.test.ts b/typescript/test/Supermarket.test.ts index 86be68e..876608b 100644 --- a/typescript/test/Supermarket.test.ts +++ b/typescript/test/Supermarket.test.ts @@ -36,4 +36,150 @@ describe('Supermarket', () => { assert.approximately(receiptItem.totalPrice, 2.5*1.99, 0.01); assert.equal(receiptItem.quantity, 2.5); }); + + it('Bundle discount - complete bundle', () => { + // ARRANGE + const catalog: SupermarketCatalog = new FakeCatalog(); + const toothbrush: Product = new Product("toothbrush", ProductUnit.Each); + catalog.addProduct(toothbrush, 0.99); + const toothpaste: Product = new Product("toothpaste", ProductUnit.Each); + catalog.addProduct(toothpaste, 1.79); + + const teller: Teller = new Teller(catalog); + teller.addBundleOffer([toothbrush, toothpaste], 10); + + const cart: ShoppingCart = new ShoppingCart(); + cart.addItemQuantity(toothbrush, 1); + cart.addItemQuantity(toothpaste, 1); + + // ACT + const receipt: Receipt = teller.checksOutArticlesFrom(cart); + + // ASSERT + const totalPrice = 0.99 + 1.79; // 2.78 + const expectedDiscount = totalPrice * 0.1; // 0.278 + const expectedTotal = totalPrice - expectedDiscount; // 2.502 + + assert.approximately(receipt.getTotalPrice(), expectedTotal, 0.01); + assert.equal(receipt.getDiscounts().length, 1); + assert.approximately(receipt.getDiscounts()[0].discountAmount, expectedDiscount, 0.01); + assert.equal(receipt.getItems().length, 2); + }); + + it('Bundle discount - partial bundle gets no discount', () => { + // ARRANGE + const catalog: SupermarketCatalog = new FakeCatalog(); + const toothbrush: Product = new Product("toothbrush", ProductUnit.Each); + catalog.addProduct(toothbrush, 0.99); + const toothpaste: Product = new Product("toothpaste", ProductUnit.Each); + catalog.addProduct(toothpaste, 1.79); + + const teller: Teller = new Teller(catalog); + teller.addBundleOffer([toothbrush, toothpaste], 10); + + const cart: ShoppingCart = new ShoppingCart(); + cart.addItemQuantity(toothbrush, 1); + // No toothpaste - incomplete bundle + + // ACT + const receipt: Receipt = teller.checksOutArticlesFrom(cart); + + // ASSERT + assert.approximately(receipt.getTotalPrice(), 0.99, 0.01); + assert.isEmpty(receipt.getDiscounts()); + assert.equal(receipt.getItems().length, 1); + }); + + it('Bundle discount - only complete bundles are discounted', () => { + // ARRANGE + const catalog: SupermarketCatalog = new FakeCatalog(); + const toothbrush: Product = new Product("toothbrush", ProductUnit.Each); + catalog.addProduct(toothbrush, 0.99); + const toothpaste: Product = new Product("toothpaste", ProductUnit.Each); + catalog.addProduct(toothpaste, 1.79); + + const teller: Teller = new Teller(catalog); + teller.addBundleOffer([toothbrush, toothpaste], 10); + + const cart: ShoppingCart = new ShoppingCart(); + cart.addItemQuantity(toothbrush, 2); + cart.addItemQuantity(toothpaste, 1); + + // ACT + const receipt: Receipt = teller.checksOutArticlesFrom(cart); + + // ASSERT + // Only 1 complete bundle (1 toothbrush + 1 toothpaste), extra toothbrush gets no discount + const bundlePrice = 0.99 + 1.79; // 2.78 + const bundleDiscount = bundlePrice * 0.1; // 0.278 + const extraToothbrushPrice = 0.99; + const expectedTotal = bundlePrice - bundleDiscount + extraToothbrushPrice; // 2.502 + 0.99 = 3.492 + + assert.approximately(receipt.getTotalPrice(), expectedTotal, 0.01); + assert.equal(receipt.getDiscounts().length, 1); + assert.approximately(receipt.getDiscounts()[0].discountAmount, bundleDiscount, 0.01); + assert.equal(receipt.getItems().length, 2); + }); + + it('Bundle discount - multiple complete bundles', () => { + // ARRANGE + const catalog: SupermarketCatalog = new FakeCatalog(); + const toothbrush: Product = new Product("toothbrush", ProductUnit.Each); + catalog.addProduct(toothbrush, 0.99); + const toothpaste: Product = new Product("toothpaste", ProductUnit.Each); + catalog.addProduct(toothpaste, 1.79); + + const teller: Teller = new Teller(catalog); + teller.addBundleOffer([toothbrush, toothpaste], 10); + + const cart: ShoppingCart = new ShoppingCart(); + cart.addItemQuantity(toothbrush, 2); + cart.addItemQuantity(toothpaste, 2); + + // ACT + const receipt: Receipt = teller.checksOutArticlesFrom(cart); + + // ASSERT + // 2 complete bundles + const bundlePrice = 0.99 + 1.79; // 2.78 + const totalBundlePrice = bundlePrice * 2; // 5.56 + const bundleDiscount = totalBundlePrice * 0.1; // 0.556 + const expectedTotal = totalBundlePrice - bundleDiscount; // 5.004 + + assert.approximately(receipt.getTotalPrice(), expectedTotal, 0.01); + assert.equal(receipt.getDiscounts().length, 1); + assert.approximately(receipt.getDiscounts()[0].discountAmount, bundleDiscount, 0.01); + assert.equal(receipt.getItems().length, 2); + }); + + it('Bundle discount - fractional quantities only count whole items', () => { + // ARRANGE + const catalog: SupermarketCatalog = new FakeCatalog(); + const toothbrush: Product = new Product("toothbrush", ProductUnit.Each); + catalog.addProduct(toothbrush, 0.99); + const toothpaste: Product = new Product("toothpaste", ProductUnit.Each); + catalog.addProduct(toothpaste, 1.79); + + const teller: Teller = new Teller(catalog); + teller.addBundleOffer([toothbrush, toothpaste], 10); + + const cart: ShoppingCart = new ShoppingCart(); + cart.addItemQuantity(toothbrush, 1.5); + cart.addItemQuantity(toothpaste, 1.2); + + // ACT + const receipt: Receipt = teller.checksOutArticlesFrom(cart); + + // ASSERT + // Only 1 complete bundle can be formed from floor(1.5) = 1 and floor(1.2) = 1 + const bundlePrice = 0.99 + 1.79; // 2.78 + const bundleDiscount = bundlePrice * 0.1; // 0.278 + const totalPrice = 0.99 * 1.5 + 1.79 * 1.2; // 1.485 + 2.148 = 3.633 + const expectedTotal = totalPrice - bundleDiscount; // 3.633 - 0.278 = 3.355 + + assert.approximately(receipt.getTotalPrice(), expectedTotal, 0.01); + assert.equal(receipt.getDiscounts().length, 1); + assert.approximately(receipt.getDiscounts()[0].discountAmount, bundleDiscount, 0.01); + assert.equal(receipt.getItems().length, 2); + }); });