Created
October 30, 2025 00:42
-
-
Save gohuygo/85125abebbad37a7773b9b7b582470c5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import pytest | |
| from vending import VendingMachine | |
| class TestSelect: | |
| def test_invalid_selection(self): | |
| vm = VendingMachine() | |
| with pytest.raises(Exception, match="Invalid selection"): | |
| vm.select("INVALID") | |
| def test_out_of_inventory(self): | |
| vm = VendingMachine() | |
| vm.insert_money("DOLLAR") | |
| assert vm.credit == 100 | |
| assert vm.pending_cash["DOLLAR"] == 1 | |
| assert vm.cash["DOLLAR"] == 0 | |
| with pytest.raises(Exception, match="Out of inventory"): | |
| vm.select("A1") | |
| assert vm.credit == 100 | |
| assert vm.pending_cash["DOLLAR"] == 1 | |
| assert vm.cash["DOLLAR"] == 0 | |
| def test_insufficient_credit(self): | |
| vm = VendingMachine() | |
| vm.restock("A1", 1) | |
| vm.insert_money("QUARTER") | |
| assert vm.credit == 25 | |
| assert vm.pending_cash["QUARTER"] == 1 | |
| assert vm.cash["QUARTER"] == 0 | |
| with pytest.raises(Exception, match="Insufficient credit"): | |
| vm.select("A1") | |
| assert vm.credit == 25 | |
| assert vm.pending_cash["QUARTER"] == 1 | |
| assert vm.cash["QUARTER"] == 0 | |
| class TestInsertMoney: | |
| def test_invalid_denomination(self): | |
| vm = VendingMachine() | |
| # Try inserting a fake bill | |
| with pytest.raises(ValueError, match="Invalid denomination"): | |
| vm.insert_money("TEN") | |
| def test_valid_insertion_increases_credit(self): | |
| vm = VendingMachine() | |
| vm.insert_money("DOLLAR") | |
| assert vm.credit == 100 | |
| vm.insert_money("QUARTER") | |
| assert vm.credit == 125 | |
| class TestRestock: | |
| def test_invalid_item(self): | |
| vm = VendingMachine() | |
| with pytest.raises(ValueError, match="Invalid item"): | |
| vm.restock("Z9", 10) # invalid slot | |
| def test_invalid_quantity(self): | |
| vm = VendingMachine() | |
| with pytest.raises(ValueError, match="Quantity must be positive"): | |
| vm.restock("A1", 0) | |
| with pytest.raises(ValueError, match="Quantity must be positive"): | |
| vm.restock("A1", -5) | |
| def test_valid_restock(self): | |
| vm = VendingMachine() | |
| assert vm.items["A1"]["quantity"] == 0 | |
| assert vm.items["B2"]["quantity"] == 0 | |
| assert vm.items["C3"]["quantity"] == 0 | |
| vm.restock("A1", 5) | |
| assert vm.items["A1"]["quantity"] == 5 | |
| assert vm.items["B2"]["quantity"] == 0 | |
| assert vm.items["C3"]["quantity"] == 0 | |
| vm.restock("A1", 3) | |
| assert vm.items["A1"]["quantity"] == 8 | |
| assert vm.items["B2"]["quantity"] == 0 | |
| assert vm.items["C3"]["quantity"] == 0 | |
| class TestLoadMoney: | |
| def test_load_invalid_denom(self): | |
| vm = VendingMachine() | |
| with pytest.raises(ValueError, match="Invalid denomination"): | |
| vm.load_money("TEN", 5) # Not a valid denomination | |
| def test_invalid_count_zero(self): | |
| vm = VendingMachine() | |
| with pytest.raises(ValueError, match="Count must be positive"): | |
| vm.load_money("DOLLAR", 0) | |
| def test_invalid_count_negative(self): | |
| vm = VendingMachine() | |
| with pytest.raises(ValueError, match="Count must be positive"): | |
| vm.load_money("DOLLAR", -3) | |
| def test_valid_load_money(self): | |
| vm = VendingMachine() | |
| assert vm.cash["DOLLAR"] == 0 | |
| assert vm.cash["QUARTER"] == 0 | |
| assert vm.cash["FIVE"] == 0 | |
| # Load valid denominations | |
| vm.load_money("DOLLAR", 5) | |
| assert vm.cash["DOLLAR"] == 5 | |
| assert vm.cash["QUARTER"] == 0 | |
| assert vm.cash["FIVE"] == 0 | |
| vm.load_money("QUARTER", 2) | |
| assert vm.cash["DOLLAR"] == 5 | |
| assert vm.cash["QUARTER"] == 2 | |
| assert vm.cash["FIVE"] == 0 | |
| vm.load_money("FIVE", 1) | |
| assert vm.cash["DOLLAR"] == 5 | |
| assert vm.cash["QUARTER"] == 2 | |
| assert vm.cash["FIVE"] == 1 | |
| class TestRefund: | |
| def test_refund_with_no_purchase(self): | |
| vm = VendingMachine() | |
| vm.insert_money("DOLLAR") | |
| vm.insert_money("QUARTER") | |
| result = vm.refund() | |
| assert result == {"DOLLAR": 1, "QUARTER": 1} | |
| assert all(count == 0 for count in vm.pending_cash.values()) | |
| assert vm.credit == 0 | |
| def test_refund_after_purchase_and_valid_change(self): | |
| vm = VendingMachine() | |
| # Setup machine with $5 for change | |
| vm.load_money("QUARTER", 4) | |
| vm.load_money("DOLLAR", 4) | |
| vm.restock("A1", 1) | |
| assert vm.cash['QUARTER'] == 4 | |
| assert vm.cash['DOLLAR'] == 4 | |
| assert vm.cash['FIVE'] == 0 | |
| assert vm.pending_cash['QUARTER'] == 0 | |
| assert vm.pending_cash['DOLLAR'] == 0 | |
| assert vm.pending_cash['FIVE'] == 0 | |
| # Insert $5 for a $1 soda | |
| vm.insert_money("FIVE") | |
| assert vm.cash['QUARTER'] == 4 | |
| assert vm.cash['DOLLAR'] == 4 | |
| assert vm.cash['FIVE'] == 0 | |
| assert vm.pending_cash['QUARTER'] == 0 | |
| assert vm.pending_cash['DOLLAR'] == 0 | |
| assert vm.pending_cash['FIVE'] == 1 | |
| item = vm.select("A1") | |
| assert item == "soda" | |
| assert vm.credit == 400 # $4 remaining credit | |
| # Pending cash should be moved over to cash reserve | |
| assert vm.cash['QUARTER'] == 4 | |
| assert vm.cash['DOLLAR'] == 4 | |
| assert vm.cash['FIVE'] == 1 | |
| assert vm.pending_cash['QUARTER'] == 0 | |
| assert vm.pending_cash['DOLLAR'] == 0 | |
| assert vm.pending_cash['FIVE'] == 0 | |
| # Refund $4 remaining amount | |
| result = vm.refund() | |
| assert result == {"DOLLAR": 4} | |
| assert vm.credit == 0 | |
| # Should have an extra dollar from purchase (i.e. $6) | |
| assert vm.cash['QUARTER'] == 4 | |
| assert vm.cash['DOLLAR'] == 0 | |
| assert vm.cash['FIVE'] == 1 | |
| assert vm.pending_cash['QUARTER'] == 0 | |
| assert vm.pending_cash['DOLLAR'] == 0 | |
| assert vm.pending_cash['FIVE'] == 0 | |
| def test_pending_first_then_reserve(self): | |
| vm = VendingMachine() | |
| vm.restock("A1", 1) | |
| vm.load_money("DOLLAR", 1) | |
| assert vm.cash['QUARTER'] == 0 | |
| assert vm.cash['DOLLAR'] == 1 | |
| assert vm.cash['FIVE'] == 0 | |
| assert vm.pending_cash['QUARTER'] == 0 | |
| assert vm.pending_cash['DOLLAR'] == 0 | |
| assert vm.pending_cash['FIVE'] == 0 | |
| vm.insert_money("DOLLAR") | |
| assert vm.cash['QUARTER'] == 0 | |
| assert vm.cash['DOLLAR'] == 1 | |
| assert vm.cash['FIVE'] == 0 | |
| assert vm.pending_cash['QUARTER'] == 0 | |
| assert vm.pending_cash['DOLLAR'] == 1 | |
| assert vm.pending_cash['FIVE'] == 0 | |
| vm.select("A1") | |
| vm.insert_money("QUARTER") | |
| vm.insert_money("QUARTER") | |
| assert vm.pending_cash['QUARTER'] == 2 | |
| assert vm.pending_cash['DOLLAR'] == 0 | |
| assert vm.pending_cash['FIVE'] == 0 | |
| assert vm.cash['QUARTER'] == 0 | |
| assert vm.cash['DOLLAR'] == 2 | |
| assert vm.cash['FIVE'] == 0 | |
| assert vm.credit == 50 | |
| change = vm.refund() | |
| assert change == {"QUARTER": 2} | |
| assert vm.credit == 0 | |
| assert vm.pending_cash['QUARTER'] == 0 | |
| assert vm.pending_cash['DOLLAR'] == 0 | |
| assert vm.pending_cash['FIVE'] == 0 | |
| assert vm.cash['QUARTER'] == 0 | |
| assert vm.cash['DOLLAR'] == 2 | |
| assert vm.cash['FIVE'] == 0 | |
| def test_refund_with_invalid_change(self): | |
| vm = VendingMachine() | |
| vm.restock("A1", 1) | |
| # Do not load machine with cash | |
| assert vm.cash['QUARTER'] == 0 | |
| assert vm.cash['DOLLAR'] == 0 | |
| assert vm.cash['FIVE'] == 0 | |
| # Insert $5 for a $1 soda | |
| vm.insert_money("FIVE") | |
| assert vm.pending_cash['FIVE'] == 1 | |
| assert vm.pending_cash['DOLLAR'] == 0 | |
| assert vm.pending_cash['QUARTER'] == 0 | |
| assert vm.cash['FIVE'] == 0 | |
| assert vm.cash['DOLLAR'] == 0 | |
| assert vm.cash['QUARTER'] == 0 | |
| item = vm.select("A1") | |
| assert item == "soda" | |
| assert vm.credit == 400 | |
| assert vm.pending_cash['FIVE'] == 0 | |
| assert vm.pending_cash['DOLLAR'] == 0 | |
| assert vm.pending_cash['QUARTER'] == 0 | |
| assert vm.cash['FIVE'] == 1 | |
| assert vm.cash['DOLLAR'] == 0 | |
| assert vm.cash['QUARTER'] == 0 | |
| # Machine cannot make exactly $4 with only a $5 bill available | |
| with pytest.raises(Exception, match="Unable to make exact change"): | |
| vm.refund() | |
| # Credit should remain | |
| assert vm.credit == 400 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| VALID_DENOMINATIONS = { | |
| 'QUARTER': 25, | |
| 'DOLLAR': 100, | |
| 'FIVE': 500, | |
| } | |
| class VendingMachine: | |
| """ | |
| Problem: OOP design problem to create a functional VendingMachine! | |
| """ | |
| def __init__(self): | |
| self.items = { | |
| "A1": { | |
| "quantity": 0, | |
| "price": 100, | |
| "item": "soda" | |
| }, | |
| "B2": { | |
| "quantity": 0, | |
| "price": 50, | |
| "item": "chips" | |
| }, | |
| "C3": { | |
| "quantity": 0, | |
| "price": 200, | |
| "item": "health_bar" | |
| } | |
| } | |
| # Internal system's cash reserve | |
| self.cash = { | |
| 'QUARTER': 0, | |
| 'DOLLAR': 0, | |
| 'FIVE': 0, | |
| } | |
| # User's submitted cash | |
| self.pending_cash = { | |
| 'QUARTER': 0, | |
| 'DOLLAR': 0, | |
| 'FIVE': 0, | |
| } | |
| self.credit = 0 | |
| def insert_money(self, denomination: str): | |
| """ | |
| Insert a single coin/bill into the pending bucket | |
| [Customer operation] | |
| """ | |
| # Raise error if invalid bill/denomination | |
| if denomination not in VALID_DENOMINATIONS: | |
| raise ValueError("Invalid denomination") | |
| # Increase current user's credit | |
| self.pending_cash[denomination] += 1 | |
| self.credit += VALID_DENOMINATIONS[denomination] | |
| def select(self, item_key: str): | |
| """ | |
| Attempt to vend an item | |
| [Customer operation] | |
| """ | |
| # Check if item exists | |
| if item_key not in self.items: | |
| raise Exception("Invalid selection") | |
| selected_item = self.items[item_key] | |
| # Check to see if item can be purchased | |
| if selected_item['quantity'] == 0: | |
| raise Exception("Out of inventory") | |
| # Check if we have enough credit to purchase item | |
| if self.credit < selected_item['price']: | |
| raise Exception("Insufficient credit") | |
| # Update internal system cash reserve | |
| for denom, count in self.pending_cash.items(): | |
| self.cash[denom] += count | |
| self.pending_cash = {d: 0 for d in VALID_DENOMINATIONS.keys()} | |
| # decrease quantity of item | |
| selected_item['quantity'] -= 1 | |
| # reduce the credit by item price | |
| self.credit -= selected_item['price'] | |
| return selected_item['item'] | |
| def refund(self): | |
| """ | |
| 1) Always give back all pending inserts first. | |
| 2) If more is owed, make change from machine cash reserve. | |
| """ | |
| amount = self.credit | |
| if amount == 0: | |
| return {} | |
| # 1) Always give back user inserts | |
| pending_change = {denom: count for denom, count in self.pending_cash.items() if count > 0} | |
| pending_value = sum(self.pending_cash[d] * VALID_DENOMINATIONS[d] for d in self.pending_cash) | |
| remaining_owed = amount - pending_value | |
| if remaining_owed < 0: | |
| # Shouldn't happen but guard anyway | |
| raise Exception("Invalid remaining amount owed to user") | |
| # 2) Return remaining owed from machine cash reserve | |
| change_from_reserve = {} | |
| if remaining_owed > 0: | |
| change_from_reserve, remaining = self._make_change(remaining_owed, self.cash) | |
| if remaining != 0: | |
| raise Exception("Unable to make exact change") | |
| # Clear pending | |
| self.pending_cash = {denom: 0 for denom in VALID_DENOMINATIONS} | |
| # Deduct planned coins/bills from machine cash | |
| for denom, count in change_from_reserve.items(): | |
| self.cash[denom] -= count | |
| # Zero out credit | |
| self.credit = 0 | |
| # Merge user insert change and machine reserve change for final change dict | |
| change = pending_change | |
| for denom, count in change_from_reserve.items(): | |
| change[denom] = change.get(denom, 0) + count | |
| return change | |
| def restock(self, item_key: str, quantity: int): | |
| """ | |
| Increase quantity in a slot (admin operation). | |
| [ADMIN operation] | |
| """ | |
| # make sure we can only restock valid items | |
| if item_key not in self.items: | |
| raise ValueError("Invalid item") | |
| # Make sure we dont take items out of vending during restock | |
| if quantity <= 0: | |
| raise ValueError("Quantity must be positive") | |
| self.items[item_key]["quantity"] += quantity | |
| def load_money(self, denomination: str, count: int): | |
| """ | |
| Add coins/bills into the machine's change inventory (admin operation). | |
| [ADMIN operation] | |
| """ | |
| # make sure we can only load valid denomination | |
| if denomination not in VALID_DENOMINATIONS: | |
| raise ValueError("Invalid denomination") | |
| # make sure we don't take out bills | |
| if count <= 0: | |
| raise ValueError("Count must be positive") | |
| self.cash[denomination] += count | |
| def _make_change(self, amount: int, cash_inventory: dict): | |
| """ | |
| Return (change, remaining_cents) using greedy change limited by cash_inventory. | |
| Loop and start with largest bill and attempt to make as much change as possible | |
| Repeat with a lower denomination each time | |
| Exit early if exact change is produced | |
| """ | |
| change = {} | |
| remaining = amount | |
| for denom, value in sorted(VALID_DENOMINATIONS.items(), key=lambda x: x[1], reverse=True): | |
| if remaining == 0: | |
| break | |
| available = cash_inventory.get(denom, 0) | |
| needed = remaining // value | |
| use = min(available, needed) | |
| if use: | |
| change[denom] = use | |
| remaining -= use * value | |
| return change, remaining |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment