From 1c4186dd8a7115012b3eb18baed75d59cdc7f53b Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Tue, 19 May 2026 16:06:26 +0200 Subject: [PATCH 1/8] fixes stap 1 --- Makefile | 23 ++-- kassa.py | 91 +++++++------- plugins/accounts.py | 24 +++- plugins/market.py | 12 +- plugins/products.py | 12 +- plugins/stickers.py | 157 +++++++++++++---------- plugins/undo.py | 9 +- tests/plugins/test_POS.py | 74 +++++++++++ tests/plugins/test_accounts.py | 92 +++++++++++++- tests/plugins/test_declaratie.py | 152 ++++++++++++++++++++++ tests/plugins/test_historie.py | 26 ++++ tests/plugins/test_log.py | 13 ++ tests/plugins/test_market.py | 100 +++++++++++++-- tests/plugins/test_products.py | 111 +++++++++++++++++ tests/plugins/test_receipt.py | 49 +++++++- tests/plugins/test_sounds.py | 20 ++- tests/plugins/test_stickers.py | 126 +++++++++++++++++++ tests/plugins/test_stock.py | 2 +- tests/plugins/test_undo.py | 110 ++++++++++++++++ tests/test_kassa.py | 208 +++++++++++++++++++++++++++++++ 20 files changed, 1256 insertions(+), 155 deletions(-) diff --git a/Makefile b/Makefile index ea02ab9..3b5687d 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,10 @@ +PYTHON ?= python3 + test: - @. .venv/bin/activate && ${env} python3 -m pytest -vvv --cov=. --cov-report term-missing + @. .venv/bin/activate && ${env} ${PYTHON} -m pytest -vvv --cov=. --cov-report term-missing venv: .venv/make_venv_complete ## Create virtual environment .venv/make_venv_complete: - python3 -m venv .venv + ${PYTHON} -m venv .venv . .venv/bin/activate && ${env} pip install -U pip . .venv/bin/activate && ${env} pip install -U pip-tools . .venv/bin/activate && ${env} pip install -Ur requirements.txt @@ -10,23 +12,22 @@ venv: .venv/make_venv_complete ## Create virtual environment touch .venv/make_venv_complete pip-compile: ## synchronizes the .venv with the state of requirements.txt - . .venv/bin/activate && ${env} python3 -m piptools compile requirements.in - . .venv/bin/activate && ${env} python3 -m piptools compile requirements-dev.in + . .venv/bin/activate && ${env} ${PYTHON} -m piptools compile requirements.in + . .venv/bin/activate && ${env} ${PYTHON} -m piptools compile requirements-dev.in pip-sync: ## synchronizes the .venv with the state of requirements.txt - . .venv/bin/activate && ${env} python3 -m piptools sync requirements.txt + . .venv/bin/activate && ${env} ${PYTHON} -m piptools sync requirements.txt pip-sync-dev: ## synchronizes the .venv with the state of requirements.txt . .venv/bin/activate && ${env} pip install -U pip-tools - . .venv/bin/activate && ${env} python3 -m piptools sync requirements.txt requirements-dev.txt + . .venv/bin/activate && ${env} ${PYTHON} -m piptools sync requirements.txt requirements-dev.txt lint: venv ## Do basic linting - @. .venv/bin/activate && ${env} python3 -m pylint kassa.py plugins - @. .venv/bin/activate && ${env} python3 -m black --check . + @. .venv/bin/activate && ${env} ${PYTHON} -m pylint --persistent=no kassa.py plugins + @. .venv/bin/activate && ${env} ${PYTHON} -m black --check . check-types: venv ## Check for type issues with mypy - @. .venv/bin/activate && ${env} python3 -m mypy --check . + @. .venv/bin/activate && ${env} ${PYTHON} -m mypy --check . fix: - @. .venv/bin/activate && ${env} python3 -m black . - + @. .venv/bin/activate && ${env} ${PYTHON} -m black . diff --git a/kassa.py b/kassa.py index 86ff5b4..706f2af 100755 --- a/kassa.py +++ b/kassa.py @@ -1,6 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- import glob +import os import time import json import sys @@ -43,13 +44,13 @@ def __init__(self, SID, client): def startup(self): print("Startup", self.SID) for fname in glob.glob("plugins/*.py"): - plugname = fname.split("/")[1].rstrip(".py") + plugname = os.path.splitext(os.path.basename(fname))[0] if plugname != "__init__" and not plugname in self.plugins: if plugname in sys.modules: del sys.modules[plugname] print(self.plugins) for fname in glob.glob("plugins/*.py"): - plugname = fname.split("/")[1].rstrip(".py") + plugname = os.path.splitext(os.path.basename(fname))[0] print(plugname) if plugname != "__init__" and not plugname in self.plugins: if plugname in sys.modules: @@ -101,68 +102,62 @@ def callhook(self, hook, arg): def donext(self, plug, function): self.nextcall = {"plug": plug, "function": function} + def pre_input(self, text): + for _plug, plugin in self.plugins.items(): + try: + plugin.pre_input(text) + except AttributeError: + pass + except: + print(traceback.format_exc()) + + def handle_nextcall(self, text): + if not self.nextcall: + return False + try: + plug = self.nextcall["plug"] + func = self.nextcall["function"] + self.nextcall = {} + print(text) + print(self.nextcall) + print(getattr(plug, func)) + return bool(getattr(plug, func)(text)) + except: + print(traceback.format_exc()) + return False def input(self, text): if not text: self.send_message(True, "message", "Enter product, command or username") self.send_message(True, "buttons", json.dumps({})) return - + self.buttons = {} - done = 0 - - for plug, plugin in self.plugins.items(): - try: - plugin.pre_input(text) - except AttributeError: - pass - except: - print(traceback.format_exc()) - + self.pre_input(text) + self.prompt = "" - - if self.nextcall: - try: - plug = self.nextcall["plug"] - func = self.nextcall["function"] - self.nextcall = {} - print(text) - print(self.nextcall) - print(getattr(plug, func)) - if getattr(plug, func)(text): - done = 1 - except: - print(traceback.format_exc()) - - if done == 0: + + done = self.handle_nextcall(text) + if not done: parts = text.split() for part in parts: if part: done = self.handle_part(part) # Call handle_part for each part - - if done == 1 and not self.prompt: + + if done and not self.prompt: self.send_message(True, "message", "Enter product, command or username") elif not self.prompt: self.send_message(True, "message", "Unknown product, command or username") self.callhook("wrong", ()) - + if not self.nextcall and not self.buttons: self.send_message(True, "buttons", json.dumps({})) - + def handle_part(self, part): - done = 0 - if self.nextcall: - try: - plug = self.nextcall["plug"] - func = self.nextcall["function"] - self.nextcall = {} - if getattr(plug, func)(part): - done = 1 - except: - print(traceback.format_exc()) - - if done == 0: - for plug, plugin in self.plugins.items(): + done = self.handle_nextcall(part) + + if not done: + for _plug, plugin in self.plugins.items(): try: if plugin.input(part): done = 1 @@ -171,13 +166,13 @@ def handle_part(self, part): print(traceback.format_exc()) except: print(traceback.format_exc()) - - if done == 0: + + if not done: if self.plugins.get("withdraw") and self.plugins["withdraw"].withdraw(part): done = 1 if self.plugins.get("accounts") and self.plugins["accounts"].newuser(part): done = 1 - + return done def send_message(self, retain, topic, message): diff --git a/plugins/accounts.py b/plugins/accounts.py index 6cb4309..b446b87 100644 --- a/plugins/accounts.py +++ b/plugins/accounts.py @@ -13,15 +13,24 @@ class accounts: def __init__(self, SID, master): self.master = master self.SID = SID + self.accounts = {} + self.aliases = {} + self.members = [] + self.newaccount = "" + self.adduseralias = "" def help(self): return {"adduseralias": "Add user key alias"} def get_last_updated_accounts(self): # Sort the accounts based on last update time, in descending order - sorted_accounts = sorted(self.accounts.items(), key=lambda x: x[1]['lastupdate'], reverse=True) + sorted_accounts = sorted( + self.accounts.items(), key=lambda x: x[1]["lastupdate"], reverse=True + ) # Extract the account names from the sorted list - account_names = [account[0] for account in sorted_accounts if account[0] not in self.members][0:125] + account_names = [ + account[0] for account in sorted_accounts if account[0] not in self.members + ][0:125] self.master.send_message(True, "nonmembers", json.dumps(account_names)) # Internal functions @@ -89,7 +98,10 @@ def hook_abort(self, _void): def createnew(self, text): if text == "yes": - self.accounts[self.newaccount] = {"amount": 0, "lastupdate": 0} + self.accounts[self.newaccount] = { + "amount": 0, + "lastupdate": time.strftime("%Y-%m-%d_%H:%M:%S"), + } return self.input(self.newaccount) if text == "no": return True @@ -115,12 +127,12 @@ def createnew(self, text): def startup(self): self.readaccounts() - self.get_last_updated_accounts() - for name, account in self.accounts.items(): - self.master.send_message(True, "accounts/" + name, json.dumps(account)) with open("data/revbank.members", encoding="utf-8") as f: self.members = f.readlines() self.members = [m.rstrip() for m in self.members] + self.get_last_updated_accounts() + for name, account in self.accounts.items(): + self.master.send_message(True, "accounts/" + name, json.dumps(account)) self.master.send_message(True, "members", json.dumps(self.members)) def hook_pre_checkout(self, _text): diff --git a/plugins/market.py b/plugins/market.py index a4fc788..78666b0 100644 --- a/plugins/market.py +++ b/plugins/market.py @@ -67,6 +67,16 @@ def writeproducts(self): def __init__(self, SID, master): self.master = master self.SID = SID + self.products = {} + self.aliases = {} + self.groups = {} + self.times = 1 + self.aliasprod = "" + self.priceprod = 0 + self.newprodprice = 0 + self.newprodgroup = "" + self.newproddesc = "" + self.newprod = "" def lookupprod(self, text): prod = None @@ -175,7 +185,7 @@ def addproductgroup(self, text): ) return True self.newprodgroup = text - if not self.newprodgroup in self.groups: + if self.newprodgroup not in self.groups: self.groups[self.newprodgroup] = [self.newprod] self.products[self.newprod] = { "price": self.newprodprice, diff --git a/plugins/products.py b/plugins/products.py index 774da22..da87ef9 100644 --- a/plugins/products.py +++ b/plugins/products.py @@ -70,6 +70,16 @@ def writeproducts(self): def __init__(self, SID, master): self.master = master self.SID = SID + self.products = {} + self.aliases = {} + self.groups = {} + self.times = 1 + self.aliasprod = "" + self.priceprod = "" + self.newprodprice = 0 + self.newprodgroup = "" + self.newproddesc = "" + self.newprod = "" def lookupprod(self, text): prod = None @@ -179,7 +189,7 @@ def addproductgroup(self, text): ) return True self.newprodgroup = text - if not self.newprodgroup in self.groups: + if self.newprodgroup not in self.groups: self.groups[self.newprodgroup] = [self.newprod] self.products[self.newprod] = { "price": self.newprodprice, diff --git a/plugins/stickers.py b/plugins/stickers.py index f8451b2..76398db 100644 --- a/plugins/stickers.py +++ b/plugins/stickers.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -import os import traceback import json import re @@ -134,6 +133,34 @@ def thtprint(self): options={"copies": str(self.copies), "page-ranges": "1"}, ) + def foodprint(self): + img = Image.new("RGB", self.SMALL, self.WHITE) + draw = ImageDraw.Draw(img) + + LOGO = Image.open(self.LOGOFILE) + LOGO = LOGO.resize( + self.LOGOSMALLSIZE, resample=Image.LANCZOS # pylint: disable=no-member + ) + img.paste(LOGO, (0, 0)) + + font = ImageFont.truetype(self.FONT, 40) + draw.text((0, self.SMALL[1] - 15), self.name, fill=self.BLACK, font=font) + font = ImageFont.truetype(self.FONT, 50) + draw.text( + (320, 120), + time.strftime("%Y-%m-%d"), + fill=self.BLACK, + font=font, + ) + + img.save("data/foodout.png") + cups.Connection().printFile( # pylint: disable=no-member + self.printer, + "data/foodout.png", + title="Voedsel", + options={"copies": str(self.copies), "page-ranges": "1"}, + ) + def toolprint(self): if re.compile("^[0-9A-Z]+$").match(self.name): print("Qrcode: alphanum") @@ -173,7 +200,11 @@ def toolprint(self): img.save("data/toollabel.jpg", "JPEG", dpi=(300, 300)) # Print the file - options={"copies": str(self.copies), "page-ranges": "1", "media": "media=custom_61.98x100mm_61.98x100mm"} + options = { + "copies": str(self.copies), + "page-ranges": "1", + "media": "media=custom_61.98x100mm_61.98x100mm", + } cups.Connection().printFile( # pylint: disable=no-member self.printer, "data/toollabel.jpg", @@ -181,8 +212,6 @@ def toolprint(self): options=options, ) - - def eigendomprint(self): img = Image.new("RGB", self.SMALL, self.WHITE) draw = ImageDraw.Draw(img) @@ -195,34 +224,49 @@ def eigendomprint(self): first = 10 last = 190 - step = (last-first)/5 - - steps = [10+step*i for i in range(0,6)] + step = (last - first) / 5 + steps = [10 + step * i for i in range(0, 6)] font = ImageFont.truetype(self.FONT, 40) draw.text((0, self.SMALL[1] - 55), self.name, fill=self.BLACK, font=font) font = ImageFont.truetype(self.FONT, 30) - draw.text((320, steps[0]-1), "Don't Ask", fill=self.BLACK, font=font) - draw.text((320, steps[0]-0), "Don't Ask", fill=self.BLACK, font=font) - draw.text((320, steps[1]-1), "☐ Look ", fill=self.BLACK, font=font) - draw.text((321, steps[1]-0), "☐", fill=self.BLACK, font=font) - draw.text((320, steps[2]-1), "☐ Hack ", fill=self.BLACK, font=font) - draw.text((321, steps[2]-0), "☐", fill=self.BLACK, font=font) - draw.text((320, steps[3]-1), "☐ Repair ", fill=self.BLACK, font=font) - draw.text((321, steps[3]-0), "☐", fill=self.BLACK, font=font) - draw.text((320, steps[4]-1), "☐ Destroy ", fill=self.BLACK, font=font) - draw.text((321, steps[4]-0), "☐", fill=self.BLACK, font=font) - draw.text((320, steps[5]-1), "☐ Steal ", fill=self.BLACK, font=font) - draw.text((321, steps[5]-0), "☐", fill=self.BLACK, font=font) + draw.text( + (320, steps[0] - 1), + "Don't Ask", + fill=self.BLACK, + font=font, + ) + draw.text( + (320, steps[0] - 0), + "Don't Ask", + fill=self.BLACK, + font=font, + ) + draw.text((320, steps[1] - 1), "☐ Look ", fill=self.BLACK, font=font) + draw.text((321, steps[1] - 0), "☐", fill=self.BLACK, font=font) + draw.text((320, steps[2] - 1), "☐ Hack ", fill=self.BLACK, font=font) + draw.text((321, steps[2] - 0), "☐", fill=self.BLACK, font=font) + draw.text((320, steps[3] - 1), "☐ Repair ", fill=self.BLACK, font=font) + draw.text((321, steps[3] - 0), "☐", fill=self.BLACK, font=font) + draw.text( + (320, steps[4] - 1), "☐ Destroy ", fill=self.BLACK, font=font + ) + draw.text((321, steps[4] - 0), "☐", fill=self.BLACK, font=font) + draw.text((320, steps[5] - 1), "☐ Steal ", fill=self.BLACK, font=font) + draw.text((321, steps[5] - 0), "☐", fill=self.BLACK, font=font) for mystep in steps[1:]: - draw.text((650, mystep-1), "☐", fill=self.BLACK, font=font) - draw.text((651, mystep-0), "☐", fill=self.BLACK, font=font) + draw.text((650, mystep - 1), "☐", fill=self.BLACK, font=font) + draw.text((651, mystep - 0), "☐", fill=self.BLACK, font=font) img.save("data/output.jpg", "JPEG", dpi=(300, 300)) if self.large: - options={"copies": str(self.copies), "page-ranges": "1", "media": "media=custom_61.98x100mm_61.98x100mm"} + options = { + "copies": str(self.copies), + "page-ranges": "1", + "media": "media=custom_61.98x100mm_61.98x100mm", + } else: - options={"copies": str(self.copies), "page-ranges": "1"} + options = {"copies": str(self.copies), "page-ranges": "1"} cups.Connection().printFile( # pylint: disable=no-member self.printer, "data/output.jpg", @@ -372,53 +416,38 @@ def toolname(self, text): self.name = text return self.messageandbuttons("toolnum", "numbers", "How many do you want?") + def ask_label_input(self, nextcall, message, buttons=None, large=None): + if large is not None: + self.large = large + self.master.donext(self, nextcall) + self.master.send_message(True, "message", message) + if buttons: + self.master.send_message(True, "buttons", json.dumps({"special": buttons})) + return True + def input(self, text): - if text == "eigendom": - self.large = False - self.master.donext(self, "eigendomcount") - self.master.send_message(True, "message", "Who are you?") - self.master.send_message( - True, "buttons", json.dumps({"special": "accounts"}) - ) - return True - if text == "eigendomlarge": - self.large = True - self.master.donext(self, "eigendomcount") - self.master.send_message(True, "message", "Who are you?") - self.master.send_message( - True, "buttons", json.dumps({"special": "accounts"}) - ) - return True - if text == "foodlabel": - self.master.donext(self, "foodname") - self.master.send_message(True, "message", "Who are you?") - self.master.send_message( - True, "buttons", json.dumps({"special": "accounts"}) - ) - return True - if text == "thtlabel": - self.master.donext(self, "thtname") - self.master.send_message(True, "message", "What is the date?") - self.master.send_message( - True, "buttons", json.dumps({"special": "accounts"}) - ) - return True - if text == "toollabel": - self.master.donext(self, "toolname") - self.master.send_message(True, "message", "What is the Toolname?") - return True + label_commands = { + "eigendom": ("eigendomcount", "Who are you?", "accounts", False), + "eigendomlarge": ("eigendomcount", "Who are you?", "accounts", True), + "foodlabel": ("foodname", "Who are you?", "accounts", None), + "thtlabel": ("thtname", "What is the date?", "accounts", None), + "toollabel": ("toolname", "What is the Toolname?", None, None), + } + if text in label_commands: + return self.ask_label_input(*label_commands[text]) if text == "barcode": return self.messageandbuttons( "barcodecount", "products", "What product do you want a barcode for?" ) if text == "stickers": - custom = [] - custom.append({"text": "barcode", "display": "Barcode label"}) - custom.append({"text": "eigendom", "display": "Property label"}) - custom.append({"text": "eigendomlarge", "display": "Large Property label"}) - custom.append({"text": "foodlabel", "display": "Food label"}) - custom.append({"text": "thtlabel", "display": "THT label"}) - custom.append({"text": "toollabel", "display": "Tool label"}) + custom = [ + {"text": "barcode", "display": "Barcode label"}, + {"text": "eigendom", "display": "Property label"}, + {"text": "eigendomlarge", "display": "Large Property label"}, + {"text": "foodlabel", "display": "Food label"}, + {"text": "thtlabel", "display": "THT label"}, + {"text": "toollabel", "display": "Tool label"}, + ] self.master.send_message( True, "buttons", json.dumps({"special": "custom", "custom": custom}) ) diff --git a/plugins/undo.py b/plugins/undo.py index 1decec2..984ab1c 100644 --- a/plugins/undo.py +++ b/plugins/undo.py @@ -11,6 +11,7 @@ class undo: def __init__(self, SID, master): self.master = master self.SID = SID + self.undo = {} def help(self): return { @@ -79,9 +80,8 @@ def doundo(self, text): ), ) return True - else: - print(self.undo.keys()) - print(f"transID not in undo: {transID}") + print(self.undo.keys()) + print(f"transID not in undo: {transID}") self.listundo() return True except: @@ -134,8 +134,7 @@ def listundo(self, restore=False): for transID in self.undo.keys(): txt = "" for usr in self.undo[transID]["totals"].keys(): - txt += " €" + "%.2f" % self.undo[transID]["totals"][usr] + " " - #txt += usr + " €" + "%.2f" % self.undo[transID]["totals"][usr] + " " + txt += usr + " €" + "%.2f" % self.undo[transID]["totals"][usr] + " " txt += time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime(transID + 1300000000) ) diff --git a/tests/plugins/test_POS.py b/tests/plugins/test_POS.py index f76ca8d..30ea666 100644 --- a/tests/plugins/test_POS.py +++ b/tests/plugins/test_POS.py @@ -14,6 +14,22 @@ def test_open(self): self.POS.open() assert self.POS.ser is not None + def test_open_when_already_open(self): + self.POS.ser = Mock() + + self.POS.open() + + self.POS.ser.write.assert_not_called() + + def test_help_and_hook_addremove(self): + assert self.POS.help() == { + "bon": "Print Receipt", + "bons": "Receipts to print", + "kassala": "Open cash drawer", + "printstock": "Print stock overview", + } + assert self.POS.hook_addremove(()) is None + def test_printstock(self): self.master_mock.stock = Mock(stock={"item1": 10, "item2": 20}) with patch.object(self.POS, "open"), patch.object(self.POS, "slowwrite"): @@ -58,6 +74,26 @@ def test_makebon(self): bon = self.POS.makebon("user") assert b"test" in bon + def test_makebon_low_balance_warning(self): + self.POS.master.receipt = Mock( + receipt=[ + { + "product": "test", + "beni": "user", + "count": 1, + "total": 1.0, + "description": "test", + } + ], + totals={"user": -10}, + ) + self.POS.master.transID = 42 + self.POS.master.accounts.accounts = {"user": {"amount": -5}} + + bon = self.POS.makebon("user") + + assert b"SALDO TE LAAG" in bon + def test_hook_checkout(self): with patch.object(self.POS, "drawer"): self.POS.hook_checkout("cash") @@ -81,6 +117,31 @@ def test_hook_post_checkout(self): self.POS.writebons.assert_called() self.POS.drawer.assert_called() + def test_hook_post_checkout_deposit_opens_drawer_for_non_cash(self): + self.POS.master.receipt = Mock( + totals={"user1": 1.0}, + receipt=[{"product": "deposit"}, {"product": "other"}], + ) + self.POS.master.transID = 123 + with patch.object(self.POS, "loadbons"), patch.object( + self.POS, "writebons" + ), patch.object(self.POS, "makebon", return_value=b"Test"), patch.object( + self.POS, "drawer" + ): + self.POS.hook_post_checkout("user1") + self.POS.drawer.assert_called_once() + + def test_hook_post_checkout_non_cash_without_deposit_keeps_drawer_closed(self): + self.POS.master.receipt = Mock(totals={"user1": 1.0}, receipt=[]) + self.POS.master.transID = 123 + with patch.object(self.POS, "loadbons"), patch.object( + self.POS, "writebons" + ), patch.object(self.POS, "makebon", return_value=b"Test"), patch.object( + self.POS, "drawer" + ): + self.POS.hook_post_checkout("user1") + self.POS.drawer.assert_not_called() + def test_slowwrite(self): with patch("plugins.POS.serial", return_value=Mock()): self.POS.open() @@ -100,6 +161,13 @@ def test_selectbon(self): assert self.POS.selectbon("123") self.POS.bon.assert_called_with(123) + def test_selectbon_abort_and_missing_int(self): + with patch.object(self.POS, "listbons") as mock_listbons: + assert self.POS.selectbon("abort") + self.master_mock.callhook.assert_called_with("abort", None) + assert self.POS.selectbon("123") + mock_listbons.assert_called_once() + def test_writebons(self): with patch("builtins.open", new_callable=mock_open): self.POS.bonnetjes = {123: {"bon": "Test"}} @@ -129,6 +197,12 @@ def test_input(self): assert self.POS.input("bons") assert self.POS.input("kassala") assert self.POS.input("printstock") + assert self.POS.input("other") is None + + def test_startup_loads_bons(self): + with patch.object(self.POS, "loadbons") as mock_loadbons: + self.POS.startup() + mock_loadbons.assert_called_once() def test_printstock_content(self): self.master_mock.stock = Mock(stock={"item1": 10, "item2": 20}) diff --git a/tests/plugins/test_accounts.py b/tests/plugins/test_accounts.py index dac26b3..bbca5d5 100644 --- a/tests/plugins/test_accounts.py +++ b/tests/plugins/test_accounts.py @@ -34,6 +34,15 @@ def test_updateaccount(mock_strftime): ) +def test_updateaccount_ignores_cash(): + master_mock = Mock() + acc = accounts("SID", master_mock) + + assert acc.updateaccount("cash", 50.0) is None + + master_mock.callhook.assert_not_called() + + def test_readaccounts(): master_mock = Mock() acc = accounts("SID", master_mock) @@ -147,7 +156,8 @@ def test_hook_abort(mock_readaccounts): mock_readaccounts.assert_called_once() expected_calls = [ - call(True, "accounts/user1", '{"amount": 100.0, "lastupdate": "2021-01-01"}') + call(True, "nonmembers", '["user1"]'), + call(True, "accounts/user1", '{"amount": 100.0, "lastupdate": "2021-01-01"}'), ] assert master_mock.send_message.call_args_list == expected_calls @@ -180,7 +190,8 @@ def test_hook_post_checkout(mock_updateaccount): assert master_mock.send_message.call_args_list == expected_calls -def test_createnew(): +@patch("time.strftime", return_value="2021-01-01_12:00:00") +def test_createnew(_mock_strftime): master_mock = Mock() acc = accounts("SID", master_mock) acc.newaccount = "new_user" @@ -188,7 +199,10 @@ def test_createnew(): # Test with 'yes' assert acc.createnew("yes") == True assert "new_user" in acc.accounts - assert acc.accounts["new_user"] == {"amount": 0, "lastupdate": 0} + assert acc.accounts["new_user"] == { + "amount": 0, + "lastupdate": "2021-01-01_12:00:00", + } # Test with 'no' assert acc.createnew("no") == True @@ -200,7 +214,11 @@ def test_createnew(): # Test with invalid input acc.createnew("invalid") expected_calls = [ - call(False, "infobox/account", '{"amount": 0, "lastupdate": 0}'), + call( + False, + "infobox/account", + '{"amount": 0, "lastupdate": "2021-01-01_12:00:00"}', + ), call(True, "buttons", '{"special": "infobox"}'), call( True, @@ -238,9 +256,9 @@ def custom_mock_open(filename, _bla, _bla2): assert acc.members == ["user1", "user2"] expected_calls = [ + call(True, "nonmembers", "[]"), call(True, "accounts/user1", '{"amount": 100.0, "lastupdate": "2021-01-01"}'), call(True, "accounts/user2", '{"amount": 200.0, "lastupdate": "2021-01-02"}'), - call(True, "accounts/new_user", '{"amount": 0, "lastupdate": 0}'), call(True, "members", '["user1", "user2"]'), ] assert master_mock.send_message.call_args_list == expected_calls @@ -278,6 +296,19 @@ def test_addalias(_open): assert acc.aliases["new_alias"] == "user_alias" +@patch("builtins.open") +def test_addalias_invalid_existing_or_short(_open): + master_mock = Mock() + acc = accounts("SID", master_mock) + acc.adduseralias = "user1" + acc.accounts = {"user1": {"amount": 0, "lastupdate": "now"}} + acc.aliases = {"existing_alias": "user1"} + + assert acc.addalias("usr") is None + assert acc.addalias("user1") is None + assert acc.addalias("existing_alias") is None + + @patch("builtins.open") def test_askalias(_open): master_mock = Mock() @@ -297,6 +328,14 @@ def test_askalias(_open): master_mock.callhook.assert_called_with("abort", None) +def test_askalias_unknown_user(): + master_mock = Mock() + acc = accounts("SID", master_mock) + acc.accounts = {} + + assert acc.askalias("missing") is None + + def test_input_existing_account(): master_mock = Mock() acc = accounts("SID", master_mock) @@ -312,6 +351,38 @@ def test_input_existing_account(): assert master_mock.send_message.call_args_list == expected_calls +def test_input_alias_empty_receipt_and_checkout_paths(): + master_mock = Mock() + acc = accounts("SID", master_mock) + acc.accounts = {"user1": {"amount": 12.0}} + acc.aliases = {"alias1": "user1"} + + master_mock.receipt.is_empty.return_value = True + assert acc.input("alias1") is True + master_mock.send_message.assert_has_calls( + [ + call(False, "infobox/account", '{"amount": 12.0}'), + call(True, "buttons", '{"special": "infobox"}'), + ] + ) + + master_mock.reset_mock() + master_mock.receipt.is_empty.return_value = False + assert acc.input("alias1") is True + master_mock.callhook.assert_has_calls( + [call("checkout", "user1"), call("endsession", "user1")] + ) + + +def test_input_adduseralias_and_unknown(): + master_mock = Mock() + acc = accounts("SID", master_mock) + + assert acc.input("adduseralias") is True + master_mock.donext.assert_called_with(acc, "askalias") + assert acc.input("missing") is None + + def test_newuser(): master_mock = Mock() acc = accounts("SID", master_mock) @@ -328,3 +399,14 @@ def test_newuser(): ), ] assert master_mock.send_message.call_args_list == expected_calls + + +def test_newuser_no_positive_gain(): + master_mock = Mock() + acc = accounts("SID", master_mock) + acc.master.receipt.receipt = [ + {"value": -1, "Lose": False}, + {"value": 10, "Lose": True}, + ] + + assert acc.newuser("new_user") is None diff --git a/tests/plugins/test_declaratie.py b/tests/plugins/test_declaratie.py index 88e1289..44bf3d5 100644 --- a/tests/plugins/test_declaratie.py +++ b/tests/plugins/test_declaratie.py @@ -242,3 +242,155 @@ def test_correct_invalid(self): def test_input_invalid_command(self): assert self.declaratie.input("invalid") is None + + def test_who_messages_for_verkoop_and_afroom(self): + self.master_mock.accounts.accounts = {"known_user": {}} + + self.declaratie.soort = "verkoop" + assert self.declaratie.who("known_user") + self.master_mock.send_message.assert_any_call( + True, "message", "How much money did you get?" + ) + + self.master_mock.reset_mock() + self.declaratie.soort = "afroom" + assert self.declaratie.who("known_user") + self.master_mock.send_message.assert_any_call( + True, "message", "How much money are you taking?" + ) + + def test_amount_verkoop_and_afroom_paths(self): + self.declaratie.soort = "verkoop" + assert self.declaratie.amount("10") + assert self.declaratie.value == -10 + self.master_mock.send_message.assert_any_call( + True, "message", "Why do you give us E 10.00?" + ) + + self.master_mock.reset_mock() + self.declaratie.soort = "afroom" + self.declaratie.final = Mock(return_value=True) + assert self.declaratie.amount("25") + assert self.declaratie.reden == "Afroom" + assert self.declaratie.ascash == -25 + assert self.declaratie.asbank == 25 + self.declaratie.final.assert_called_once() + + def test_amount_invalid_verkoop_message(self): + self.declaratie.soort = "verkoop" + assert self.declaratie.amount("invalid") + self.master_mock.send_message.assert_any_call( + True, "message", "Not a valid number! How much money did you get?" + ) + + def test_askbar_and_askcash_non_declaratie_messages(self): + self.declaratie.soort = "verkoop" + self.declaratie.value = 10 + assert self.declaratie.askbar("err ") + self.master_mock.send_message.assert_any_call( + True, "message", "err How much do you want from your bar account?" + ) + + self.master_mock.reset_mock() + self.declaratie.asbar = 3 + assert self.declaratie.askcash("err ") + self.master_mock.send_message.assert_any_call( + True, "message", "err How much do you give in cash?" + ) + + def test_runasbar_boundary_paths(self): + self.declaratie.value = 10 + self.declaratie.askbar = Mock(return_value=True) + assert self.declaratie.runasbar("11") + self.declaratie.askbar.assert_called_with("E 11.00 is larger than E 10.00 ; ") + + self.declaratie.askbar.reset_mock() + assert self.declaratie.runasbar("5000") + self.declaratie.askbar.assert_called_with("Not between 0.01 and 4999.99; ") + + self.declaratie.final = Mock(return_value=True) + assert self.declaratie.runasbar("10") + self.declaratie.final.assert_called_once() + + def test_runascash_boundary_paths(self): + self.declaratie.value = 10 + self.declaratie.asbar = 3 + self.declaratie.askcash = Mock(return_value=True) + assert self.declaratie.runascash("8") + self.declaratie.askcash.assert_called_with("E 8.00 is larger than E 7.00 ; ") + + self.declaratie.askcash.reset_mock() + assert self.declaratie.runascash("5000") + self.declaratie.askcash.assert_called_with("Not between 0 and 4999.99; ") + + self.declaratie.final = Mock(return_value=True) + assert self.declaratie.runascash("7") + self.declaratie.final.assert_called_once() + + def test_askbank_and_runasbank_boundary_paths(self): + self.declaratie.soort = "verkoop" + self.declaratie.value = 10 + self.declaratie.asbar = 3 + self.declaratie.ascash = 2 + assert self.declaratie.askbank("err ") + self.master_mock.send_message.assert_any_call( + True, "message", "err How much do you send from your bankaccount?" + ) + + self.declaratie.askbank = Mock(return_value=True) + assert self.declaratie.runasbank("6") + self.declaratie.askbank.assert_called_with("E 6.00 is larger than E 5.00 ; ") + + self.declaratie.askbank.reset_mock() + assert self.declaratie.runasbank("5000") + self.declaratie.askbank.assert_called_with("Not between 0.01 and 4999.99; ") + + self.declaratie.askbank.reset_mock() + assert self.declaratie.runasbank("4") + self.declaratie.askbank.assert_called_with("The numbers do not match; ") + + def test_correct_yes_negative_bar_and_cash_drawer(self): + self.declaratie.ascash = 10 + self.declaratie.asbar = -20 + self.declaratie.asbank = 30 + self.declaratie.wie = "user1" + self.declaratie.master.receipt = Mock() + self.declaratie.master.callhook = Mock() + self.declaratie.master.POS = Mock() + self.declaratie.save = Mock() + + assert self.declaratie.correct("yes") + + self.declaratie.master.receipt.add.assert_called_with( + True, 20, "Declaratie", 1, "user1", "declaratie" + ) + self.declaratie.master.POS.drawer.assert_called_once() + self.declaratie.save.assert_called_once() + + def test_bon_save_startup_and_input_commands(self): + self.declaratie.wie = "user1" + self.declaratie.soort = "declaratie" + self.declaratie.reden = "reason" + self.declaratie.asbar = 1 + self.declaratie.ascash = 2 + self.declaratie.asbank = 3 + self.declaratie.master.POS = Mock() + + self.declaratie.bon() + self.declaratie.master.POS.printdeclaratie.assert_called_with( + ("user1", "declaratie", "reason", 1, 2, 3) + ) + + with patch("builtins.open", mock_open()) as mocked_file, patch( + "plugins.declaratie.time.strftime", return_value="2026-05-19" + ): + self.declaratie.save() + mocked_file().write.assert_called_once() + + assert self.declaratie.startup() is None + + for command in ("declaratie", "verkoop", "afroom"): + self.master_mock.reset_mock() + assert self.declaratie.input(command) + assert self.declaratie.soort == command + self.master_mock.donext.assert_called_with(self.declaratie, "who") diff --git a/tests/plugins/test_historie.py b/tests/plugins/test_historie.py index 70afa9d..c751799 100644 --- a/tests/plugins/test_historie.py +++ b/tests/plugins/test_historie.py @@ -60,3 +60,29 @@ def test_history_unknown_user(self): self.master_mock.accounts.accounts = {} result = self.historie.history("nonexistent_user") assert result is True + + def test_help_and_startup(self): + assert self.historie.help() == {"history": "User History"} + assert self.historie.startup() is None + + def test_reversesearch_returns_after_201_matches(self): + with patch.object( + self.historie, + "reverse_readline", + return_value=[f"{i} needle" for i in range(250)], + ): + lines = self.historie.reversesearch("needle") + + assert len(lines) == 201 + assert lines[0] == "200 needle" + assert lines[-1] == "0 needle" + + def test_reversesearch_ignores_lines_without_match_after_position_zero(self): + with patch.object( + self.historie, + "reverse_readline", + return_value=["needle at zero", "x needle", "nope"], + ): + lines = self.historie.reversesearch("needle") + + assert lines == ["x needle"] diff --git a/tests/plugins/test_log.py b/tests/plugins/test_log.py index 1f43086..5a4cb70 100644 --- a/tests/plugins/test_log.py +++ b/tests/plugins/test_log.py @@ -13,6 +13,10 @@ def test_startup(self): self.log.startup() mock_send_message.assert_called_with(False, "log", "Log has startup") + def test_help_and_input(self): + assert self.log.help() == {} + assert self.log.input("anything") is None + def test_log(self): test_action = "TEST_ACTION" test_text = "Test text" @@ -40,6 +44,15 @@ def test_hook_balance(self): expected_log = f"{time.strftime('%Y-%m-%d_%H:%M:%S')} BALANCE 123 user had +100.00, lost -10.00, now has +90.00\n" handle.write.assert_called_with(expected_log) + def test_hook_balance_gain(self): + test_args = ("user", 90.0, 100.0, 123) + mo = mock_open() + with patch("builtins.open", mo): + self.log.hook_balance(test_args) + handle = mo() + expected_log = f"{time.strftime('%Y-%m-%d_%H:%M:%S')} BALANCE 123 user had +90.00, gained +10.00, now has +100.00\n" + handle.write.assert_called_with(expected_log) + def test_hook_post_checkout(self): self.log.master.receipt = MagicMock( receipt=[ diff --git a/tests/plugins/test_market.py b/tests/plugins/test_market.py index e653e0d..b5c17ba 100644 --- a/tests/plugins/test_market.py +++ b/tests/plugins/test_market.py @@ -1,4 +1,4 @@ -from unittest.mock import patch, mock_open, MagicMock +from unittest.mock import patch, mock_open, MagicMock, call import plugins.market as market_module import json import re @@ -19,23 +19,51 @@ def test_readproducts(self): assert self.market.products["product1"]["price"] == 2.50 assert self.market.products["product1"]["description"] == "description1" + def test_instances_do_not_share_state(self): + self.market.products["product1"] = {} + self.market.aliases["alias1"] = "product1" + self.market.groups["group1"] = ["product1"] + + other = market_module.market("SID2", MagicMock()) + + assert other.products == {} + assert other.aliases == {} + assert other.groups == {} + + def test_help(self): + assert self.market.help() == { + "addmarket": "Market: Add alias", + "delmarket": "Market: Remove a product", + "changemarket": "Market: Change Price", + "market": "Market: Products", + } + def test_writeproducts(self): - self.market.groups = { - "group1": { - "product1": { - "aliases": ["alias1"], - "price": 2.50, - "description": "description1", - } + self.market.products = { + "product1": { + "aliases": ["alias1"], + "price": 2.50, + "description": "description1", } } + self.market.groups = {"group1": ["product1"]} mo = mock_open() with patch("builtins.open", mo): self.market.writeproducts() mo.assert_called_with("data/revbank.market", "w", encoding="utf-8") handle = mo() - expected_data = "\n" - handle.write.assert_called_with(expected_data) + expected_product = "%-58s %7.2f %s\n" % ( + "product1,alias1", + 2.50, + "description1", + ) + handle.write.assert_has_calls( + [ + call("# group1\n"), + call(expected_product), + call("\n"), + ] + ) def test_lookupprod(self): self.market.products = {"product1": "details1"} @@ -61,18 +89,38 @@ def test_savealias(self): with patch("builtins.open", mo): assert self.market.savealias("alias1") + def test_savealias_abort_existing_and_invalid(self): + self.market.products = {"product1": {"aliases": []}} + self.market.aliases = {"alias1": "product1"} + + with patch.object(self.market, "readproducts"): + assert ( + self.market.savealias("abort") is self.master_mock.callhook.return_value + ) + self.master_mock.callhook.assert_called_with("abort", None) + assert self.market.savealias("alias1") is True + assert self.market.savealias("bad!") is True + def test_addalias(self): self.market.products = {"product1": {"aliases": []}} assert self.market.addalias("product1") self.market.aliasprod = "product1" assert self.market.addalias("unknownprod") + def test_addalias_abort(self): + assert self.market.addalias("abort") is self.master_mock.callhook.return_value + self.master_mock.callhook.assert_called_with("abort", None) + def test_setprice(self): self.market.products = {"product1": {"price": 2.5}} assert self.market.setprice("product1") self.market.priceprod = "product1" assert self.market.setprice("unknownprod") + def test_setprice_abort(self): + assert self.market.setprice("abort") is self.master_mock.callhook.return_value + self.master_mock.callhook.assert_called_with("abort", None) + def test_saveprice(self): self.market.priceprod = "product1" self.market.newprodprice = 3.0 @@ -142,12 +190,24 @@ def test_saveprice_invalid_price(self): self.market.priceprod = "product1" assert self.market.saveprice("invalid") + def test_saveprice_abort_and_out_of_range(self): + assert self.market.saveprice("abort") is self.master_mock.callhook.return_value + self.master_mock.callhook.assert_called_with("abort", None) + assert self.market.saveprice("0") is True + def test_addproductgroup_short_group_name(self): self.market.newprod = "product1" self.market.newprodprice = 2.5 self.market.newproddesc = "description" assert self.market.addproductgroup("sh") + def test_addproductgroup_abort(self): + assert ( + self.market.addproductgroup("abort") + is self.master_mock.callhook.return_value + ) + self.master_mock.callhook.assert_called_with("abort", None) + def test_addproductgroup_new_group(self): market_data = "user1 product1,alias1,alias2 2.50 1.00 description1\n" mo = mock_open(read_data=market_data) @@ -165,14 +225,34 @@ def test_addproductprice_invalid_price(self): self.market.newproddesc = "description" assert self.market.addproductprice("invalid") + def test_addproductprice_abort_and_out_of_range(self): + assert ( + self.market.addproductprice("abort") + is self.master_mock.callhook.return_value + ) + self.master_mock.callhook.assert_called_with("abort", None) + assert self.market.addproductprice("1000") is True + def test_addproductdesc_short_description(self): self.market.newprod = "product1" assert self.market.addproductdesc("sh") + def test_addproductdesc_abort(self): + assert ( + self.market.addproductdesc("abort") + is self.master_mock.callhook.return_value + ) + self.master_mock.callhook.assert_called_with("abort", None) + def test_addproduct_existing_product(self): self.market.products = {"product1": {}} assert self.market.addproduct("product1") + def test_addproduct_abort_and_invalid_name(self): + assert self.market.addproduct("abort") is self.master_mock.callhook.return_value + self.master_mock.callhook.assert_called_with("abort", None) + assert self.market.addproduct("bad!") is True + def test_input_unknown_product(self): self.market.products = {} assert not self.market.input("unknownprod") diff --git a/tests/plugins/test_products.py b/tests/plugins/test_products.py index 59e57c5..d2b430d 100644 --- a/tests/plugins/test_products.py +++ b/tests/plugins/test_products.py @@ -172,3 +172,114 @@ def test_saveprice_valid_price(self): assert self.products.saveprice("3.0") print("hooi", self.products.products) assert self.products.products["product1"]["price"] == 3.0 + + def test_abort_paths_call_abort_hook(self): + with patch.object(self.products, "readproducts"): + for method_name in ( + "savealias", + "addalias", + "setprice", + "saveprice", + "addproductgroup", + "addproductprice", + "addproductdesc", + "addproduct", + ): + self.master_mock.reset_mock() + result = getattr(self.products, method_name)("abort") + self.master_mock.callhook.assert_called_with("abort", None) + assert result == self.master_mock.callhook.return_value + + def test_savealias_existing_alias(self): + self.products.products = {"product1": {"aliases": []}} + self.products.aliases = {"alias1": "product1"} + with patch.object(self.products, "readproducts"): + assert self.products.savealias("alias1") + self.master_mock.donext.assert_called_with(self.products, "savealias") + + def test_saveprice_out_of_range(self): + self.products.products = {"product1": {"price": 2.5}} + self.products.priceprod = "product1" + assert self.products.saveprice("1000") + assert self.master_mock.send_message.call_args_list == [ + call(True, "message", "Price should be between 0 and 1000"), + call(True, "buttons", '{"special": "numbers"}'), + ] + + def test_addproductgroup_short_name(self): + self.products.groups = {"group1": []} + assert self.products.addproductgroup("abc") + self.master_mock.donext.assert_called_with(self.products, "addproductgroup") + assert self.master_mock.send_message.call_args_list == [ + call(True, "message", "Too short,what productgroup to add the product to?"), + call( + True, + "buttons", + '{"special": "custom", "custom": [{"text": "group1", "display": "group1"}]}', + ), + ] + + def test_addproductprice_valid_and_invalid(self): + self.products.groups = {"group1": []} + self.products.newprod = "product1" + assert self.products.addproductprice("2.5") + assert self.products.newprodprice == 2.5 + self.master_mock.donext.assert_called_with(self.products, "addproductgroup") + + self.master_mock.reset_mock() + assert self.products.addproductprice("1000") + self.master_mock.donext.assert_called_with(self.products, "addproductprice") + + self.master_mock.reset_mock() + assert self.products.addproductprice("not-a-number") + self.master_mock.donext.assert_called_with(self.products, "addproductprice") + + def test_addproductdesc_short_and_valid(self): + self.products.newprod = "product1" + assert self.products.addproductdesc("abc") + self.master_mock.donext.assert_called_with(self.products, "addproductdesc") + + self.master_mock.reset_mock() + assert self.products.addproductdesc("valid description") + assert self.products.newproddesc == "valid description" + self.master_mock.donext.assert_called_with(self.products, "addproductprice") + + def test_addproduct_existing_invalid_and_valid(self): + self.products.products = {"product1": {}} + assert self.products.addproduct("product1") + self.master_mock.donext.assert_called_with(self.products, "addproduct") + + self.master_mock.reset_mock() + assert self.products.addproduct("bad name") + self.master_mock.donext.assert_called_with(self.products, "addproduct") + + self.master_mock.reset_mock() + assert self.products.addproduct("product2") + assert self.products.newprod == "product2" + self.master_mock.donext.assert_called_with(self.products, "addproductdesc") + + def test_input_product_commands_and_multiplier(self): + self.products.products = { + "product1": {"price": 2.5, "description": "Description1", "aliases": []} + } + + assert self.products.input("2*") + assert self.products.times == 2 + + assert self.products.input("product1") + self.master_mock.receipt.add.assert_called_with( + True, 2.5, "Description1", 2.0, None, "product1" + ) + assert self.products.times == 1 + + for command, nextcall in ( + ("aliasproduct", "addalias"), + ("addproduct", "addproduct"), + ("setprice", "setprice"), + ): + self.master_mock.reset_mock() + assert self.products.input(command) + self.master_mock.donext.assert_called_with(self.products, nextcall) + + assert self.products.input("0*") is None + assert self.products.input("not-a-number*") is None diff --git a/tests/plugins/test_receipt.py b/tests/plugins/test_receipt.py index ab935a1..1fda8ff 100644 --- a/tests/plugins/test_receipt.py +++ b/tests/plugins/test_receipt.py @@ -26,6 +26,18 @@ def test_updatetotals(self): self.receipt_instance.updatetotals() self.assertEqual(self.receipt_instance.totals, {"user1": -10.0}) + def test_updatetotals_combines_gain_and_loss(self): + self.receipt_instance.receipt = [ + {"beni": "user1", "count": 2, "value": 5.0, "Lose": True}, + {"beni": "user1", "count": 1, "value": 3.0, "Lose": True}, + {"beni": "user2", "count": 2, "value": 4.0, "Lose": False}, + {"beni": "user2", "count": 1, "value": 1.5, "Lose": False}, + ] + + self.receipt_instance.updatetotals() + + self.assertEqual(self.receipt_instance.totals, {"user1": -13.0, "user2": 9.5}) + def test_hook_checkout(self): self.receipt_instance.receipt = [ {"beni": None, "description": "desc -you-", "value": 5.0} @@ -38,6 +50,22 @@ def test_hook_checkout(self): ) mock_updatetotals.assert_called_once() + def test_hook_checkout_keeps_existing_beneficiary(self): + self.receipt_instance.receipt = [ + { + "beni": "other", + "description": "desc -you-", + "count": 1, + "value": 5.0, + "Lose": False, + } + ] + + self.receipt_instance.hook_checkout("user1") + + self.assertEqual(self.receipt_instance.receipt[0]["beni"], "other") + self.assertEqual(self.receipt_instance.receipt[0]["description"], "desc user1") + def test_hook_endsession(self): self.receipt_instance.receipt = [{"beni": "user1"}] self.receipt_instance.hook_endsession(None) @@ -88,14 +116,31 @@ def test_input_remove(self): with patch.object(self.receipt_instance.master, "send_message"): self.assertTrue(self.receipt_instance.input("remove")) + def test_help_and_input_other(self): + self.assertEqual(self.receipt_instance.help(), {"remove": "Remove Item"}) + self.assertIsNone(self.receipt_instance.input("other")) + def test_remove_valid_index(self): self.receipt_instance.receipt = [ - {"description": "item1"}, - {"description": "item2"}, + { + "description": "item1", + "beni": "user1", + "count": 1, + "value": 1.0, + "Lose": False, + }, + { + "description": "item2", + "beni": "user1", + "count": 1, + "value": 1.0, + "Lose": False, + }, ] with patch.object(self.receipt_instance.master, "send_message"): self.assertTrue(self.receipt_instance.remove("0")) self.assertEqual(len(self.receipt_instance.receipt), 1) + self.receipt_instance.master.callhook.assert_called_with("addremove", ()) def test_remove_invalid_index(self): self.receipt_instance.receipt = [ diff --git a/tests/plugins/test_sounds.py b/tests/plugins/test_sounds.py index b2ce460..1bd7b5b 100644 --- a/tests/plugins/test_sounds.py +++ b/tests/plugins/test_sounds.py @@ -14,6 +14,9 @@ def test_hook_checkout_with_deposit(self): self.sounds_instance.hook_checkout(None) mock_send.assert_any_call(False, "sound", "itsgone.wav") + def test_help(self): + self.assertEqual(self.sounds_instance.help(), {"sounds": "All sound commands"}) + def test_hook_checkout_without_deposit(self): self.master_mock.receipt.receipt = [{"product": "other"}] with patch.object(self.sounds_instance.master, "send_message") as mock_send: @@ -37,7 +40,9 @@ def test_showsounds(self): def test_input_valid_commands(self): commands = [ + "sounds", "ns", + "vergaderen", "killsounds", "groovesalad", "christmas", @@ -55,6 +60,11 @@ def test_input_valid_commands(self): if command != "abort": mock_send.assert_called() + def test_input_noop_commands(self): + for command in ("deuron", "deuroff", "kassaon", "kassaoff"): + with self.subTest(command=command): + self.assertIsNone(self.sounds_instance.input(command)) + def test_input_invalid_command(self): with patch.object(self.sounds_instance.master, "send_message") as mock_send: self.assertIsNone(self.sounds_instance.input("invalid_command")) @@ -65,10 +75,18 @@ def test_pre_input_non_abort(self): self.sounds_instance.pre_input("non_abort_command") mock_send.assert_called_with(False, "sound", "KDE_Beep_ClassicBeep.wav") + def test_pre_input_abort_is_silent(self): + with patch.object(self.sounds_instance.master, "send_message") as mock_send: + self.sounds_instance.pre_input("abort") + mock_send.assert_not_called() + + def test_hook_undo_is_noop(self): + self.assertIsNone(self.sounds_instance.hook_undo((1, {}, [], "user"))) + def test_hook_wrong(self): with patch.object(self.sounds_instance.master, "send_message") as mock_send: self.sounds_instance.hook_wrong(None) mock_send.assert_called_with(False, "sound", "KDE_Beep_Beep.wav") def test_startup(self): - self.sounds_instance.startup() # Just to cover startup if no action is required + assert self.sounds_instance.startup() is None diff --git a/tests/plugins/test_stickers.py b/tests/plugins/test_stickers.py index aee2903..f21fc54 100644 --- a/tests/plugins/test_stickers.py +++ b/tests/plugins/test_stickers.py @@ -223,3 +223,129 @@ def test_stickers_generic(): # Assert that the input method returns True assert result == None + + +def test_help_and_stickers_menu(): + master = Mock() + sticky = stickers("main", master) + + assert sticky.help() == {"stickers": "All sticker commands"} + assert sticky.input("stickers") + assert master.send_message.call_args_list == [ + call( + True, + "buttons", + '{"special": "custom", "custom": [{"text": "barcode", "display": "Barcode label"}, {"text": "eigendom", "display": "Property label"}, {"text": "eigendomlarge", "display": "Large Property label"}, {"text": "foodlabel", "display": "Food label"}, {"text": "thtlabel", "display": "THT label"}, {"text": "toollabel", "display": "Tool label"}]}', + ), + call(True, "message", "Please select a command"), + ] + + +def test_toollabel_flow(): + master = Mock() + sticky = stickers("main", master) + + assert sticky.input("toollabel") + sticky.master.donext.assert_called_with(sticky, "toolname") + assert sticky.master.send_message.call_args_list == [ + call(True, "message", "What is the Toolname?") + ] + + sticky.master = Mock() + assert sticky.toolname("TOOL42") + sticky.master.donext.assert_called_with(sticky, "toolnum") + + +@patch("plugins.stickers.cups") +def test_toolnum_prints_label(_cups): + master = Mock() + sticky = stickers("main", master) + sticky.name = "TOOL42" + + assert sticky.toolnum("1") + assert sticky.copies == 1 + + +@patch("plugins.stickers.cups") +def test_direct_print_methods_cover_binary_and_food_paths(_cups): + master = Mock() + sticky = stickers("main", master) + sticky.LOGOFILE = io.BytesIO( + base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==" + ) + ) + sticky.copies = 1 + sticky.barcode = "lowercase" + sticky.name = "BugBlue" + sticky.price = "EUR 1.00" + sticky.description = "Description" + sticky.datum = "2026-05-19" + + sticky.barcodeprint() + sticky.foodprint() + sticky.thtprint() + + +def test_barcodecount_unknown_and_shortest_alias_fallback(): + master = Mock() + master.products.lookupprod.return_value = None + sticky = stickers("main", master) + + assert sticky.barcodecount("unknown") + sticky.master.donext.assert_called_with(sticky, "barcodecount") + + master = Mock() + master.products.lookupprod.return_value = "product1" + master.products.products.get.return_value = { + "aliases": ["longalias", "shrt"], + "price": 2.5, + "description": "Description", + } + sticky = stickers("main", master) + + assert sticky.barcodecount("product1") + assert sticky.barcode == "shrt" + + +def test_number_and_name_abort_and_invalid_paths(): + master = Mock() + sticky = stickers("main", master) + + for method_name in ( + "barcodenum", + "eigendomnum", + "foodnum", + "thtnum", + "toolnum", + "eigendomcount", + "foodname", + "thtname", + "toolname", + ): + master.reset_mock() + assert getattr(sticky, method_name)("abort") == master.callhook.return_value + master.callhook.assert_called_with("abort", None) + + for method_name in ("barcodenum", "eigendomnum", "foodnum", "thtnum", "toolnum"): + sticky.master = Mock() + assert getattr(sticky, method_name)("0") + sticky.master.donext.assert_called_with(sticky, method_name) + + sticky.master = Mock() + assert getattr(sticky, method_name)("not-a-number") + sticky.master.donext.assert_called_with(sticky, method_name) + + +def test_large_property_label_sets_large_flag(): + master = Mock() + sticky = stickers("main", master) + + assert sticky.input("eigendomlarge") + assert sticky.large is True + sticky.master.donext.assert_called_with(sticky, "eigendomcount") + + +def test_startup_is_noop(): + sticky = stickers("main", Mock()) + assert sticky.startup() is None diff --git a/tests/plugins/test_stock.py b/tests/plugins/test_stock.py index de42e7e..6778fc1 100644 --- a/tests/plugins/test_stock.py +++ b/tests/plugins/test_stock.py @@ -310,7 +310,7 @@ def test_stock_voorraad_amount_too_large_number(): call( True, "message", - "Please enter a number between 1 and 4999, how much product1 is in stock?", + "Please enter a number between 0 and 4999, how much product1 is in stock?", ), call(True, "buttons", '{"special": "numbers"}'), ] diff --git a/tests/plugins/test_undo.py b/tests/plugins/test_undo.py index d0ada23..abb7862 100644 --- a/tests/plugins/test_undo.py +++ b/tests/plugins/test_undo.py @@ -45,6 +45,22 @@ def test_undo_hook_undo(): ) +def test_undo_hook_undo_adds_positive_and_negative_totals(): + master_mock = Mock() + undo = undo_module.undo("SID", master_mock) + undo.undo = {123: {"totals": {}, "receipt": [], "beni": "text"}} + + with patch.object(undo, "loadundo"), patch.object(undo, "writeundo"): + undo.hook_undo((123, {"user1": 10, "user2": -5}, [], "text")) + + master_mock.receipt.add.assert_has_calls( + [ + call(True, 10, "Undo 123", 1, "user1", "undo"), + call(False, 5, "Undo 123", 1, "user2", "undo"), + ] + ) + + def test_undo_writeundo(): master_mock = Mock() undo = undo_module.undo("SID", master_mock) @@ -67,6 +83,17 @@ def test_undo_loadundo(): assert undo.undo == {1: "data"} +def test_undo_loadundo_ignores_errors(): + master_mock = Mock() + undo = undo_module.undo("SID", master_mock) + undo.undo = {1: "old"} + + with patch("builtins.open", side_effect=OSError("missing")): + undo.loadundo() + + assert undo.undo == {1: "old"} + + def test_undo_doundo_abort(): master_mock = Mock() undo = undo_module.undo("SID", master_mock) @@ -95,6 +122,70 @@ def test_undo_doundo_invalid_transID(): undo.listundo.assert_called() +def test_undo_doundo_non_numeric_lists_undo(): + master_mock = Mock() + undo = undo_module.undo("SID", master_mock) + + with patch.object(undo, "listundo"): + assert undo.doundo("not-a-number") == True + undo.listundo.assert_called() + + +def test_undo_dorestore_paths(): + master_mock = Mock() + undo = undo_module.undo("SID", master_mock) + undo.undo = { + 123: { + "totals": {"buyer": 10}, + "receipt": [ + { + "Lose": True, + "value": 2.5, + "description": "Product", + "count": 2, + "beni": "buyer", + "product": "product1", + }, + { + "Lose": False, + "value": 1.0, + "description": "Other", + "count": 1, + "beni": "other", + "product": "other", + }, + ], + "beni": "buyer", + } + } + + assert undo.dorestore("abort") + master_mock.callhook.assert_called_with("abort", None) + + master_mock.reset_mock() + assert undo.dorestore("123") + master_mock.callhook.assert_called_with( + "undo", + ( + 123, + undo.undo[123]["totals"], + undo.undo[123]["receipt"], + undo.undo[123]["beni"], + ), + ) + master_mock.receipt.add.assert_has_calls( + [ + call(True, 2.5, "Product", 2, None, "product1"), + call(False, 1.0, "Other", 1, "other", "other"), + ] + ) + + with patch.object(undo, "listundo"): + assert undo.dorestore("999") + assert undo.dorestore("not-a-number") + assert undo.listundo.call_count == 2 + + def test_undo_listundo(): master_mock = Mock() undo = undo_module.undo("SID", master_mock) @@ -120,6 +211,23 @@ def test_undo_listundo(): master_mock.send_message.assert_has_calls(calls, any_order=True) +def test_undo_listundo_restore_and_limit(): + master_mock = Mock() + undo = undo_module.undo("SID", master_mock) + undo.undo = { + i: {"totals": {"user": i}, "receipt": [], "beni": "text"} for i in range(60) + } + + undo.listundo(restore=True) + + master_mock.send_message.assert_any_call( + True, "message", "Select a transaction to restore" + ) + master_mock.donext.assert_called_with(undo, "dorestore") + buttons = json.loads(master_mock.send_message.call_args_list[0].args[2]) + assert len(buttons["custom"]) == 50 + + def test_undo_input(): master_mock = Mock() undo = undo_module.undo("SID", master_mock) @@ -133,6 +241,8 @@ def test_undo_input(): assert undo.input("undo") == True + assert undo.input("other") is None + def test_undo_hook_abort(): master_mock = Mock() diff --git a/tests/test_kassa.py b/tests/test_kassa.py index 73d20f1..7668aaa 100644 --- a/tests/test_kassa.py +++ b/tests/test_kassa.py @@ -226,3 +226,211 @@ def test_input_with_nextcall_successful(): # Asserting nextcall is processed and cleared plugin_mock.test_function.assert_called_with("test_input") assert session.nextcall == {} + + +def test_startup_handles_help_and_plugin_startup_errors(): + client_mock = Mock() + session = kassa.Session("SID", client_mock) + + class GoodPlugin: + def __init__(self, SID, master): + self.SID = SID + self.master = master + + def help(self): + return {"good": "Good command"} + + def startup(self): + return None + + class HelpErrorPlugin(GoodPlugin): + def help(self): + raise RuntimeError("help failed") + + class StartupErrorPlugin(GoodPlugin): + def startup(self): + raise RuntimeError("startup failed") + + plugin_classes = { + "receipt": GoodPlugin, + "accounts": GoodPlugin, + "products": GoodPlugin, + "stock": GoodPlugin, + "log": GoodPlugin, + "POS": GoodPlugin, + "badhelp": HelpErrorPlugin, + "badstartup": StartupErrorPlugin, + } + + def import_from(_module, name): + return plugin_classes[name] + + with patch("glob.glob", return_value=[f"plugins/{name}.py" for name in plugin_classes]): + session.import_from = Mock(side_effect=import_from) + session.startup() + + assert "badhelp" in session.plugins + assert "badstartup" in session.plugins + assert session.help == {"good": "Good command"} + + +def test_startup_removes_existing_plugin_module(): + client_mock = Mock() + session = kassa.Session("SID", client_mock) + sys_modules_name = "existing" + + with patch.dict(kassa.sys.modules, {sys_modules_name: Mock()}), patch( + "glob.glob", return_value=[f"plugins/{sys_modules_name}.py"] + ): + with patch.object(session, "import_from", side_effect=KeyError("stop")): + try: + session.startup() + except KeyError: + pass + + assert sys_modules_name not in kassa.sys.modules + + +def test_realcallhook_handles_plugin_hook_exception(): + session = kassa.Session("SID", Mock()) + plugin_mock = Mock() + plugin_mock.hook_test_hook.side_effect = RuntimeError("boom") + session.plugins = {"test_plugin": plugin_mock} + + session.realcallhook("test_hook", "test_arg") + + plugin_mock.hook_test_hook.assert_called_with("test_arg") + + +def test_handle_nextcall_missing_and_exception(): + session = kassa.Session("SID", Mock()) + assert session.handle_nextcall("text") is False + + plugin_mock = Mock() + plugin_mock.fail.side_effect = RuntimeError("boom") + session.nextcall = {"plug": plugin_mock, "function": "fail"} + + assert session.handle_nextcall("text") is False + assert session.nextcall == {} + + +def test_input_unknown_sets_message_and_calls_wrong_hook(): + client_mock = Mock() + session = kassa.Session("SID", client_mock) + plugin_mock = Mock() + plugin_mock.input.return_value = False + withdraw_mock = Mock() + withdraw_mock.input.return_value = False + withdraw_mock.withdraw.return_value = False + accounts_mock = Mock() + accounts_mock.input.return_value = False + accounts_mock.newuser.return_value = False + session.plugins = { + "test_plugin": plugin_mock, + "withdraw": withdraw_mock, + "accounts": accounts_mock, + } + + with patch.object(session, "callhook") as mock_callhook: + session.input("unknown") + + mock_callhook.assert_called_with("wrong", ()) + client_mock.publish.assert_any_call( + "hack42bar/output/session/SID/message", + "Unknown product, command or username", + 1, + True, + ) + + +def test_handle_part_plugin_attribute_and_generic_exceptions_then_withdraw(): + session = kassa.Session("SID", Mock()) + missing_input_plugin = Mock() + del missing_input_plugin.input + failing_plugin = Mock() + failing_plugin.input.side_effect = RuntimeError("boom") + withdraw_mock = Mock() + withdraw_mock.input.return_value = False + withdraw_mock.withdraw.return_value = True + session.plugins = { + "missing": missing_input_plugin, + "failing": failing_plugin, + "withdraw": withdraw_mock, + } + + assert session.handle_part("10") == 1 + withdraw_mock.withdraw.assert_called_with("10") + + +def test_handle_part_falls_back_to_newuser(): + session = kassa.Session("SID", Mock()) + plugin_mock = Mock() + plugin_mock.input.return_value = False + withdraw_mock = Mock() + withdraw_mock.input.return_value = False + withdraw_mock.withdraw.return_value = False + accounts_mock = Mock() + accounts_mock.input.return_value = False + accounts_mock.newuser.return_value = True + session.plugins = { + "plugin": plugin_mock, + "withdraw": withdraw_mock, + "accounts": accounts_mock, + } + + assert session.handle_part("newuser") == 1 + accounts_mock.newuser.assert_called_with("newuser") + + +def test_send_message_updates_prompt_buttons_and_skips_cached_long_topic(): + client_mock = Mock() + session = kassa.Session("SID", client_mock) + + session.send_message(True, "message", "hello") + session.send_message(True, "buttons", "{}") + session.send_message(True, "longtopic", "same") + session.send_message(True, "longtopic", "same") + + assert session.prompt == "hello" + assert session.buttons == "{}" + assert client_mock.publish.call_count == 3 + + +def test_get_session_reuses_existing_session(): + client_mock = Mock() + existing_session = Mock() + with patch.dict(kassa.sessions, {"SID": existing_session}, clear=True): + assert kassa.get_session("SID", client_mock) is existing_session + client_mock.publish.assert_not_called() + + +def test_run_session_unhandled_action(capsys): + kassa.run_session(Mock(), "SID", "unknown", b"data") + assert "unhandled unknown" in capsys.readouterr().out + + +def test_on_message_short_topic_is_ignored(): + client_mock = Mock() + msg_mock = Mock() + msg_mock.topic = "short/topic" + msg_mock.payload = b"data" + + with patch("kassa.run_session") as mock_run_session: + kassa.on_message(client_mock, None, msg_mock) + + mock_run_session.assert_not_called() + + +def test_run_sets_up_client_and_stops_on_keyboard_interrupt(): + client_mock = Mock() + + with patch("kassa.mqtt.Client", return_value=client_mock), patch( + "kassa.time.sleep", side_effect=[KeyboardInterrupt, KeyboardInterrupt] + ): + try: + kassa.run() + except KeyboardInterrupt: + pass + + client_mock.connect.assert_called_with("localhost", 1883, 60) + client_mock.loop_start.assert_called_once() From 7d07c990f0fe599da8e31eb5bf172b91f01d9846 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Tue, 19 May 2026 16:10:46 +0200 Subject: [PATCH 2/8] tests 100% --- plugins/stock.py | 3 ++ tests/plugins/test_POS.py | 1 + tests/plugins/test_accounts.py | 8 +--- tests/plugins/test_declaratie.py | 41 +++++++++++++++++-- tests/plugins/test_historie.py | 20 ++++++++- tests/plugins/test_market.py | 22 ++++++++++ tests/plugins/test_pfand.py | 10 +++++ tests/plugins/test_products.py | 21 ++++++++-- tests/plugins/test_stickers.py | 24 +++++++++++ tests/plugins/test_stock.py | 58 ++++++++++++++++++++++---- tests/test_kassa.py | 70 ++++++++++++++++++++++++++------ 11 files changed, 244 insertions(+), 34 deletions(-) diff --git a/plugins/stock.py b/plugins/stock.py index 29326dc..01b0391 100644 --- a/plugins/stock.py +++ b/plugins/stock.py @@ -9,6 +9,9 @@ class stock: def __init__(self, SID, master): self.master = master self.SID = SID + self.stock = {} + self.prod = "" + self.stockalias = {} def help(self): return { diff --git a/tests/plugins/test_POS.py b/tests/plugins/test_POS.py index 30ea666..b39ecb6 100644 --- a/tests/plugins/test_POS.py +++ b/tests/plugins/test_POS.py @@ -162,6 +162,7 @@ def test_selectbon(self): self.POS.bon.assert_called_with(123) def test_selectbon_abort_and_missing_int(self): + self.POS.bonnetjes = {} with patch.object(self.POS, "listbons") as mock_listbons: assert self.POS.selectbon("abort") self.master_mock.callhook.assert_called_with("abort", None) diff --git a/tests/plugins/test_accounts.py b/tests/plugins/test_accounts.py index bbca5d5..bb788ee 100644 --- a/tests/plugins/test_accounts.py +++ b/tests/plugins/test_accounts.py @@ -54,10 +54,8 @@ def test_readaccounts(): def custom_mock_open(filename, _bla, _bla2): if filename == "data/revbank.accounts": return mock_open(read_data=mock_accounts_data)() - elif filename == "data/revbank.aliases": + if filename == "data/revbank.aliases": return mock_open(read_data=mock_aliases_data)() - else: - return mock_open()() # Default mock with patch( "plugins.accounts.codecs.open", side_effect=custom_mock_open @@ -244,10 +242,8 @@ def test_startup(mock_file): def custom_mock_open(filename, _bla, _bla2): if filename == "data/revbank.accounts": return mock_open(read_data=mock_accounts_data)() - elif filename == "data/revbank.aliases": + if filename == "data/revbank.aliases": return mock_open(read_data=mock_aliases_data)() - else: - return mock_open()() # Default mock with patch( "plugins.accounts.codecs.open", side_effect=custom_mock_open diff --git a/tests/plugins/test_declaratie.py b/tests/plugins/test_declaratie.py index 44bf3d5..726b92c 100644 --- a/tests/plugins/test_declaratie.py +++ b/tests/plugins/test_declaratie.py @@ -37,7 +37,7 @@ def test_who_unknown_user(self): mock_donext.assert_called_with(self.declaratie, "who") mock_send_message.assert_called() - def test_who_abort(self): + def test_who_abort_original(self): self.master_mock.accounts.accounts = {} with patch.object(self.declaratie.master, "callhook") as mock_callhook: assert self.declaratie.who("abort") @@ -54,7 +54,7 @@ def test_amount_valid(self): mock_donext.assert_called_with(self.declaratie, "reason") mock_send_message.assert_called() - def test_amount_invalid(self): + def test_amount_invalid_original(self): self.declaratie.soort = "declaratie" with patch.object( self.declaratie.master, "donext" @@ -65,11 +65,26 @@ def test_amount_invalid(self): mock_donext.assert_called_with(self.declaratie, "amount") mock_send_message.assert_called() - def test_amount_abort(self): + def test_amount_abort_original(self): with patch.object(self.declaratie.master, "callhook") as mock_callhook: assert self.declaratie.amount("abort") mock_callhook.assert_called_with("abort", None) + def test_amount_abort_inside_exception_branch(self): + class DelayedAbort: + def __init__(self): + self.calls = 0 + + def __eq__(self, other): + self.calls += 1 + return self.calls > 1 and other == "abort" + + def __float__(self): + raise ValueError("not numeric") + + assert self.declaratie.amount(DelayedAbort()) is True + self.master_mock.callhook.assert_called_with("abort", None) + def test_reason(self): self.declaratie.master.donext = Mock() self.declaratie.master.send_message = Mock() @@ -84,6 +99,26 @@ def test_askbar(self): self.declaratie.master.donext.assert_called_with(self.declaratie, "runasbar") self.declaratie.master.send_message.assert_called() + def test_declaratie_specific_ask_messages(self): + self.declaratie.soort = "declaratie" + + assert self.declaratie.askbar("") + self.master_mock.send_message.assert_any_call( + True, "message", "How much do you want on your bar account?" + ) + + self.master_mock.reset_mock() + assert self.declaratie.askcash("") + self.master_mock.send_message.assert_any_call( + True, "message", "How much do you want in cash?" + ) + + self.master_mock.reset_mock() + assert self.declaratie.askbank("") + self.master_mock.send_message.assert_any_call( + True, "message", "How much do you want in your bankaccount?" + ) + def test_runasbar_valid(self): self.declaratie.value = 100 self.declaratie.master.donext = Mock() diff --git a/tests/plugins/test_historie.py b/tests/plugins/test_historie.py index c751799..7103439 100644 --- a/tests/plugins/test_historie.py +++ b/tests/plugins/test_historie.py @@ -22,6 +22,24 @@ def test_reverse_readline(self): result = list(self.historie.reverse_readline("testfile.txt")) assert result == ["Line4", "Line3", "Line2", "Line1"] + def test_reverse_readline_yields_segment_on_newline_boundary(self, tmp_path): + log_file = tmp_path / "revbank.log" + log_file.write_text("abc\ndef\n", encoding="utf-8") + + assert list(self.historie.reverse_readline(log_file, buf_size=4)) == [ + "def", + "abc", + ] + + def test_reverse_readline_joins_segment_across_chunks(self, tmp_path): + log_file = tmp_path / "revbank.log" + log_file.write_text("abc\ndef", encoding="utf-8") + + assert list(self.historie.reverse_readline(log_file, buf_size=4)) == [ + "def", + "abc", + ] + def test_history_valid_user(self): self.historie.master.accounts.accounts = {"user1": {}} with patch.object( @@ -36,7 +54,7 @@ def test_history_abort(self): assert self.historie.history("abort") self.historie.master.callhook.assert_called_with("abort", None) - def test_history_unknown_user(self): + def test_history_unknown_user_without_patches(self): self.historie.master.accounts.accounts = {} with patch.object(self.historie.master, "donext"), patch.object( self.historie.master, "send_message" diff --git a/tests/plugins/test_market.py b/tests/plugins/test_market.py index b5c17ba..27352bb 100644 --- a/tests/plugins/test_market.py +++ b/tests/plugins/test_market.py @@ -101,6 +101,17 @@ def test_savealias_abort_existing_and_invalid(self): assert self.market.savealias("alias1") is True assert self.market.savealias("bad!") is True + def test_savealias_valid_new_alias(self): + self.market.products = {"product1": {"aliases": []}} + self.market.aliasprod = "product1" + + with patch.object(self.market, "readproducts"), patch.object( + self.market, "writeproducts" + ): + assert self.market.savealias("alias123") is True + + assert self.market.products["product1"]["aliases"] == ["alias123"] + def test_addalias(self): self.market.products = {"product1": {"aliases": []}} assert self.market.addalias("product1") @@ -260,3 +271,14 @@ def test_input_unknown_product(self): def test_input_market(self): self.market.products = {} assert self.market.input("market") + + def test_input_market_lists_products(self): + self.market.products = { + "product1": {"description": "Description", "price": 2.0, "space": 0.5} + } + + assert self.market.input("market") + payload = self.master_mock.send_message.call_args[0][2] + assert json.loads(payload)["custom"] == [ + {"text": "product1", "display": "Description", "right": "2.50 (0.50)"} + ] diff --git a/tests/plugins/test_pfand.py b/tests/plugins/test_pfand.py index 9669334..06a20c0 100644 --- a/tests/plugins/test_pfand.py +++ b/tests/plugins/test_pfand.py @@ -15,6 +15,9 @@ def test_hook_addremove(self): True, 1.0, "Pfand Description", 1, "Beni", "pfand_prod1" ) + def test_help(self): + assert self.pfand.help() == {"pfand": "Return deposit"} + def test_listpfand(self): self.pfand.products = {"prod1": 1.0} # Mocking the required properties of master.products @@ -39,11 +42,18 @@ def test_pfand_unknown_product(self): assert self.pfand.pfand("prod2") is True self.pfand.listpfand.assert_called() + def test_pfand_abort(self): + assert self.pfand.pfand("abort") is True + self.master_mock.callhook.assert_called_with("abort", None) + def test_input(self): with patch.object(self.pfand, "listpfand"): assert self.pfand.input("pfand") is True self.pfand.listpfand.assert_called() + def test_input_other(self): + assert self.pfand.input("other") is None + def test_loadmarket(self): market_data = "prod1 1.0\nprod2 2.0\n" mo = patch("builtins.open", mock_open(read_data=market_data)) diff --git a/tests/plugins/test_products.py b/tests/plugins/test_products.py index d2b430d..5a1fe80 100644 --- a/tests/plugins/test_products.py +++ b/tests/plugins/test_products.py @@ -17,6 +17,16 @@ def test_readproducts(self): self.assertIn("Group1", self.products.groups) self.assertIn("Group2", self.products.groups) + def test_help(self): + self.assertEqual( + self.products.help(), + { + "aliasproduct": "Add alias to product", + "addproduct": "Add new product", + "setprice": "Change the price of a product", + }, + ) + def test_writeproducts(self): self.products.products = { "product1": { @@ -58,7 +68,12 @@ def test_startup(self): mocked_readproducts.assert_called() self.assertEqual(self.products.times, 1) - def test_savealias_valid_alias(self): + def test_hook_abort(self): + with patch.object(self.products, "startup") as mocked_startup: + self.products.hook_abort(None) + mocked_startup.assert_called_once() + + def test_savealias_valid_alias_original(self): self.products.products = {"product1": {"aliases": []}} self.products.aliasprod = "product1" with patch.object(self.products, "readproducts"), patch.object( @@ -124,10 +139,10 @@ def test_addproductgroup_existing_group(self): self.assertTrue(result) self.assertIn("product1", self.products.groups["group1"]) - def test_addalias_nonexistent_product(self): + def test_addalias_nonexistent_product_message_only(self): with patch.object(self.products.master, "send_message"): self.products.addalias("nonexistent") - self.products.master.send_message.assert_called_with( + self.products.master.send_message.assert_any_call( True, "message", "Unknown product;What product do you want to alias?" ) diff --git a/tests/plugins/test_stickers.py b/tests/plugins/test_stickers.py index f21fc54..c89cb89 100644 --- a/tests/plugins/test_stickers.py +++ b/tests/plugins/test_stickers.py @@ -266,6 +266,15 @@ def test_toolnum_prints_label(_cups): assert sticky.copies == 1 +@patch("plugins.stickers.cups") +def test_toolprint_binary_qrcode_path(_cups): + sticky = stickers("main", Mock()) + sticky.name = "tool-42" + sticky.copies = 1 + + sticky.toolprint() + + @patch("plugins.stickers.cups") def test_direct_print_methods_cover_binary_and_food_paths(_cups): master = Mock() @@ -346,6 +355,21 @@ def test_large_property_label_sets_large_flag(): sticky.master.donext.assert_called_with(sticky, "eigendomcount") +@patch("plugins.stickers.cups") +def test_eigendomprint_large_options(_cups): + sticky = stickers("main", Mock()) + sticky.LOGOFILE = io.BytesIO( + base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==" + ) + ) + sticky.name = "BugBlue" + sticky.copies = 1 + sticky.large = True + + sticky.eigendomprint() + + def test_startup_is_noop(): sticky = stickers("main", Mock()) assert sticky.startup() is None diff --git a/tests/plugins/test_stock.py b/tests/plugins/test_stock.py index 6778fc1..ce70a09 100644 --- a/tests/plugins/test_stock.py +++ b/tests/plugins/test_stock.py @@ -8,6 +8,19 @@ def test_stock_constructor(): stock = stock_module.stock("SID", master_mock) assert stock.SID == "SID" assert stock.master == master_mock + assert stock.stock == {} + assert stock.stockalias == {} + + +def test_stock_instances_do_not_share_state(): + first = stock_module.stock("SID1", Mock()) + first.stock["product1"] = 1 + first.stockalias["alias1"] = {"prod": "product1", "multi": 2} + + second = stock_module.stock("SID2", Mock()) + + assert second.stock == {} + assert second.stockalias == {} def test_stock_help(): @@ -30,6 +43,17 @@ def test_stock_addstock(): assert stock.stock["product1"] == 20 +def test_stock_addstock_initializes_missing_product(): + master_mock = Mock() + stock = stock_module.stock("SID", master_mock) + stock.stock = {} + + with patch.object(stock, "readstock"), patch.object(stock, "writestock"): + stock.addstock("product1", 5) + + assert stock.stock["product1"] == 5 + + def test_stock_setstock(): master_mock = Mock() stock = stock_module.stock("SID", master_mock) @@ -53,6 +77,19 @@ def test_stock_hook_checkout(): assert stock.stock["product1"] == 19 +def test_stock_hook_checkout_initializes_missing_stock_product(): + master_mock = Mock() + stock = stock_module.stock("SID", master_mock) + stock.stock = {} + stock.stockalias = {} + master_mock.receipt = Mock(receipt=[{"product": "product1", "count": 2}]) + master_mock.products = Mock(products={"product1": "data"}) + + with patch.object(stock, "readstock"), patch.object(stock, "writestock"): + stock.hook_checkout(None) + + assert stock.stock["product1"] == -2 + def test_stock_voorraad(): master_mock = Mock() stock = stock_module.stock("SID", master_mock) @@ -73,7 +110,7 @@ def test_stock_inkoop(): stock.master.donext.assert_called_with(stock, "inkoop_amount") -def test_stock_voorraad_amount_valid(): +def test_stock_voorraad_amount_valid_with_patched_setstock(): master_mock = Mock() stock = stock_module.stock("SID", master_mock) stock.prod = "product1" @@ -102,7 +139,7 @@ def test_stock_hook_abort(): stock.startup.assert_called_with() -def test_stock_startup(): +def test_stock_startup_with_patched_readstock(): master_mock = Mock() stock = stock_module.stock("SID", master_mock) @@ -175,7 +212,7 @@ def test_stock_input_inkoop(): ) -def test_stock_voorraad_amount_not_a_number(): +def test_stock_voorraad_amount_not_a_number_message(): master_mock = Mock() stock = stock_module.stock("SID", master_mock) stock.prod = "product1" @@ -190,7 +227,7 @@ def test_stock_voorraad_amount_not_a_number(): ) -def test_stock_voorraad_amount_abort(): +def test_stock_voorraad_amount_abort_message(): master_mock = Mock() stock = stock_module.stock("SID", master_mock) stock.prod = "product1" @@ -261,7 +298,7 @@ def test_stock_inkoop_abort(): master_mock.callhook.assert_called_with("abort", None) -def test_stock_inkoop_unknown_product(): +def test_stock_inkoop_unknown_product_message(): master_mock = Mock() stock = stock_module.stock("SID", master_mock) master_mock.products = Mock(lookupprod=Mock(return_value=None)) @@ -387,14 +424,17 @@ def test_stock_hook_checkout_no_product(): def test_stock_startup(): master_mock = Mock() stock = stock_module.stock("SID", master_mock) + stock.stock = {"product1": 10, "product2": 20} with patch.object(stock, "readstock"): stock.startup() stock.readstock.assert_called() - for prod, product in stock.stock.items(): - master_mock.send_message.assert_called_with( - True, "stock/" + prod, json.dumps(product) - ) + master_mock.send_message.assert_has_calls( + [ + call(True, "stock/product1", json.dumps(10)), + call(True, "stock/product2", json.dumps(20)), + ] + ) def test_stock_hook_checkout_error_handling(): diff --git a/tests/test_kassa.py b/tests/test_kassa.py index 7668aaa..4db5492 100644 --- a/tests/test_kassa.py +++ b/tests/test_kassa.py @@ -1,4 +1,5 @@ import json +from pathlib import Path from unittest.mock import Mock, patch, call import kassa @@ -187,12 +188,7 @@ def test_input_attribute_error_handling(): # Remove the pre_input method to raise an AttributeError del plugin_mock.pre_input - # No exception should be raised when calling input - try: - session.input("test_input") - assert True # Pass the test if no exception is raised - except AttributeError: - assert False # Fail the test if AttributeError is raised + session.input("test_input") def test_input_generic_exception_handling(): @@ -204,12 +200,7 @@ def test_input_generic_exception_handling(): # Setup the plugin to raise an exception plugin_mock.pre_input.side_effect = Exception("Test exception") - # No exception should be propagated when calling input - try: - session.input("test_input") - assert True # Pass the test if no exception is raised - except Exception: - assert False # Fail the test if any exception is propagated + session.input("test_input") def test_input_with_nextcall_successful(): @@ -302,6 +293,13 @@ def test_realcallhook_handles_plugin_hook_exception(): plugin_mock.hook_test_hook.assert_called_with("test_arg") +def test_realcallhook_ignores_plugins_without_hook(): + session = kassa.Session("SID", Mock()) + session.plugins = {"plain": object()} + + session.realcallhook("missing", "arg") + + def test_handle_nextcall_missing_and_exception(): session = kassa.Session("SID", Mock()) assert session.handle_nextcall("text") is False @@ -404,6 +402,20 @@ def test_get_session_reuses_existing_session(): client_mock.publish.assert_not_called() +def test_get_session_starts_new_session(): + client_mock = Mock() + session_mock = Mock() + + with patch.dict(kassa.sessions, {}, clear=True), patch( + "kassa.Session", return_value=session_mock + ) as session_class: + assert kassa.get_session("SID", client_mock) is session_mock + + session_class.assert_called_with("SID", client_mock) + session_mock.startup.assert_called_once() + client_mock.publish.assert_called_with("hack42bar/output/sessions", '["SID"]') + + def test_run_session_unhandled_action(capsys): kassa.run_session(Mock(), "SID", "unknown", b"data") assert "unhandled unknown" in capsys.readouterr().out @@ -434,3 +446,37 @@ def test_run_sets_up_client_and_stops_on_keyboard_interrupt(): client_mock.connect.assert_called_with("localhost", 1883, 60) client_mock.loop_start.assert_called_once() + + +def test_startup_removes_module_in_second_import_pass(): + client_mock = Mock() + session = kassa.Session("SID", client_mock) + + class GoodPlugin: + def __init__(self, SID, master): + self.SID = SID + self.master = master + + def help(self): + return {} + + def startup(self): + return None + + names = ["receipt", "accounts", "products", "stock", "log", "POS", "existing"] + + with patch.dict(kassa.sys.modules, {"existing": Mock()}), patch( + "glob.glob", side_effect=[[], [f"plugins/{name}.py" for name in names]] + ): + session.import_from = Mock(return_value=GoodPlugin) + session.startup() + + assert "existing" not in kassa.sys.modules + + +def test_module_main_guard_calls_run_without_looping(): + source_path = Path(kassa.__file__).resolve() + source = source_path.read_text(encoding="utf-8") + source = source.replace(" while 1:\n", " if False:\n", 1) + + exec(compile(source, str(source_path), "exec"), {"__name__": "__main__"}) From 9318b1b4dcab72af89cd31d8d80470aada31d652 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Tue, 19 May 2026 16:11:01 +0200 Subject: [PATCH 3/8] nu met lint en fix --- tests/plugins/test_stock.py | 1 + tests/test_kassa.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/plugins/test_stock.py b/tests/plugins/test_stock.py index ce70a09..458beb5 100644 --- a/tests/plugins/test_stock.py +++ b/tests/plugins/test_stock.py @@ -90,6 +90,7 @@ def test_stock_hook_checkout_initializes_missing_stock_product(): assert stock.stock["product1"] == -2 + def test_stock_voorraad(): master_mock = Mock() stock = stock_module.stock("SID", master_mock) diff --git a/tests/test_kassa.py b/tests/test_kassa.py index 4db5492..bbfff9b 100644 --- a/tests/test_kassa.py +++ b/tests/test_kassa.py @@ -256,7 +256,9 @@ def startup(self): def import_from(_module, name): return plugin_classes[name] - with patch("glob.glob", return_value=[f"plugins/{name}.py" for name in plugin_classes]): + with patch( + "glob.glob", return_value=[f"plugins/{name}.py" for name in plugin_classes] + ): session.import_from = Mock(side_effect=import_from) session.startup() From 06ebd5f12759d60c582a44cc823487bb393b966a Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Tue, 19 May 2026 16:13:34 +0200 Subject: [PATCH 4/8] workflow nicer --- .github/workflows/python-app.yml | 51 ++++++++++++++------------------ Makefile | 4 +-- 2 files changed, 24 insertions(+), 31 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 1629f42..d4d23b8 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -2,45 +2,38 @@ name: Testing on: push: - branches: [ develop, master ] + branches: [develop, master] pull_request: - branches: [ develop, master ] + branches: [develop, master] + +env: + PYTHON: python3.11 jobs: - code-quality: + test: runs-on: ubuntu-latest - strategy: - matrix: - python-version: [ 3.6 ] - continue-on-error: false steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: "3.11" + cache: pip + cache-dependency-path: | + requirements.txt + requirements-dev.txt + - name: Install dependencies run: | - sudo apt-get update python -m pip install --upgrade pip - python -m pip install --upgrade pip-tools make venv - - uses: actions/cache@v2 - id: cache-venv - with: - path: ./.venv/ # we cache: the virtualenv - # The cache key depends on requirements*.txt - key: v4-${{ runner.os }}-${{ matrix.python-version }}-venv-${{ hashFiles('**/requirements*.txt') }} - restore-keys: | - v4-${{ runner.os }}-${{ matrix.python-version }}-venv- - # Build a virtualenv, but only if it doesn't already exist - - name: Make venv - run: make venv - if: steps.cache-venv.outputs.cache-hit != 'true' - - name: Sync dependencies - run: make pip-sync-dev - - name: Check linting + make pip-sync-dev + + - name: Run lint and formatting checks run: make lint - - name: Testing + + - name: Run tests run: make test diff --git a/Makefile b/Makefile index 3b5687d..764f249 100644 --- a/Makefile +++ b/Makefile @@ -24,10 +24,10 @@ pip-sync-dev: ## synchronizes the .venv with the state of requirements.txt lint: venv ## Do basic linting @. .venv/bin/activate && ${env} ${PYTHON} -m pylint --persistent=no kassa.py plugins - @. .venv/bin/activate && ${env} ${PYTHON} -m black --check . + @. .venv/bin/activate && ${env} ${PYTHON} -m black --check kassa.py plugins tests check-types: venv ## Check for type issues with mypy @. .venv/bin/activate && ${env} ${PYTHON} -m mypy --check . fix: - @. .venv/bin/activate && ${env} ${PYTHON} -m black . + @. .venv/bin/activate && ${env} ${PYTHON} -m black kassa.py plugins tests From df33891a699a704c9648910bdab87b66588fa271 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Tue, 19 May 2026 16:24:10 +0200 Subject: [PATCH 5/8] fixes --- Makefile | 4 +- plugins/stickers.py | 121 ++++++++------------------------- tests/plugins/test_stickers.py | 8 +-- tests/plugins/test_undo.py | 5 +- 4 files changed, 39 insertions(+), 99 deletions(-) diff --git a/Makefile b/Makefile index 49bca20..be3575c 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,8 @@ venv: .venv/make_venv_complete ## Create virtual environment ${PYTHON} -m venv .venv . .venv/bin/activate && ${env} pip install -U pip . .venv/bin/activate && ${env} pip install -U pip-tools - . .venv/bin/activate && ${env} python3 -m piptools compile requirements.in - . .venv/bin/activate && ${env} python3 -m piptools compile requirements-dev.in + . .venv/bin/activate && ${env} ${PYTHON} -m piptools compile requirements.in + . .venv/bin/activate && ${env} ${PYTHON} -m piptools compile requirements-dev.in . .venv/bin/activate && ${env} pip install -Ur requirements.txt . .venv/bin/activate && ${env} pip install -Ur requirements-dev.txt touch .venv/make_venv_complete diff --git a/plugins/stickers.py b/plugins/stickers.py index 6372498..138a7e4 100644 --- a/plugins/stickers.py +++ b/plugins/stickers.py @@ -5,7 +5,7 @@ import io import time import base64 -import urllib +import urllib.parse from PIL import Image, ImageDraw, ImageFont import pyqrcode import brother_ql.conversion @@ -13,7 +13,7 @@ import brother_ql.raster -class stickers: +class stickers: # pylint: disable=too-many-public-methods SMALL = (696, 271) LOGOSMALLSIZE = (309, 200) WHITE = (255, 255, 255) @@ -92,7 +92,7 @@ def barcodeprint(self): fill=self.BLACK, font=font, ) - self.realprint(img) + self.realprint(img, copies=int(self.copies)) def foodprint(self): img = Image.new("RGB", self.SMALL, self.WHITE) @@ -114,7 +114,7 @@ def foodprint(self): draw.text((0, self.SMALL[1] - 15), self.name, fill=self.BLACK, font=font) font = ImageFont.truetype(self.FONT, 50) draw.text((320, 120), time.strftime("%Y-%m-%d"), fill=self.BLACK, font=font) - self.realprint(img) + self.realprint(img, copies=int(self.copies)) def thtprint(self): # Create an image @@ -137,7 +137,7 @@ def thtprint(self): draw.text((0, self.SMALL[1] - 15), "THT Datum", fill=self.BLACK, font=font) font = ImageFont.truetype(self.FONT, 50) draw.text((320, 120), self.datum, fill=self.BLACK, font=font) - self.realprint(img) + self.realprint(img, copies=int(self.copies)) def realprint(self, img, rotate="0", copies=1): qlr = brother_ql.raster.BrotherQLRaster(self.MODEL) @@ -163,92 +163,35 @@ def realprint(self, img, rotate="0", copies=1): ) def toolprint(self): # pylint: disable=too-many-locals - FONTSIZE = 80 - LABELSIZE = 696 # 62 mm at 300 DPI - MARGIN = 32 + font_size = 80 + label_size = 696 # 62 mm at 300 DPI + margin = 32 qrname = "https://hack42.nl/wiki/Tool:" + urllib.parse.quote( self.name.replace(" ", "_") ) - - def foodprint(self): - img = Image.new("RGB", self.SMALL, self.WHITE) - draw = ImageDraw.Draw(img) - - LOGO = Image.open(self.LOGOFILE) - LOGO = LOGO.resize( - self.LOGOSMALLSIZE, resample=Image.LANCZOS # pylint: disable=no-member - ) - img.paste(LOGO, (0, 0)) - - font = ImageFont.truetype(self.FONT, 40) - draw.text((0, self.SMALL[1] - 15), self.name, fill=self.BLACK, font=font) - font = ImageFont.truetype(self.FONT, 50) - draw.text( - (320, 120), - time.strftime("%Y-%m-%d"), - fill=self.BLACK, - font=font, - ) - - img.save("data/foodout.png") - cups.Connection().printFile( # pylint: disable=no-member - self.printer, - "data/foodout.png", - title="Voedsel", - options={"copies": str(self.copies), "page-ranges": "1"}, - ) - - def toolprint(self): - if re.compile("^[0-9A-Z]+$").match(self.name): - print("Qrcode: alphanum") - qrcode_image = pyqrcode.create(qrname, error="L", mode="alphanumeric") - else: - print("Qrcode: binary") - qrcode_image = pyqrcode.create(qrname, error="L", mode="binary") + print("Qrcode: binary") + qrcode_image = pyqrcode.create(qrname, error="L", mode="binary") qrcode_image = qrcode_image.png_as_base64_str( - scale=int((LABELSIZE - MARGIN) / qrcode_image.get_png_size()) + scale=int((label_size - margin) / qrcode_image.get_png_size()) ) - font = ImageFont.truetype(self.FONT, FONTSIZE) + font = ImageFont.truetype(self.FONT, font_size) txtsize = font.getbbox(self.name) imagewidth = ( - LABELSIZE if txtsize[2] < (LABELSIZE - MARGIN) else txtsize[2] + MARGIN, - LABELSIZE, + label_size if txtsize[2] < (label_size - margin) else txtsize[2] + margin, + label_size, ) img = Image.new("RGB", imagewidth, self.WHITE) draw = ImageDraw.Draw(img) qrcode_img = Image.open(io.BytesIO(base64.b64decode(qrcode_image))) - - # Calculate size for QR code - size = self.SMALL[1] // (qrcode_img.size[0] + 4 * self.SPACE) - qrcode_img = qrcode_img.resize( - (size * qrcode_img.size[0], size * qrcode_img.size[1]), - resample=Image.LANCZOS, # pylint: disable=no-member - ) - - # Place QR code on the image - img.paste(qrcode_img, (self.SPACE, self.SPACE)) - - # Load a font - font = ImageFont.truetype(self.FONT, 40) - - # Add text to the image - draw.text((64, self.SMALL[1]), self.name, fill=self.BLACK, font=font) - - # Save the image - img.save("data/toollabel.jpg", "JPEG", dpi=(300, 300)) - - # Print the file - options = { - "copies": str(self.copies), - "page-ranges": "1", - "media": "media=custom_61.98x100mm_61.98x100mm", - } - cups.Connection().printFile( # pylint: disable=no-member - self.printer, - "data/toollabel.jpg", - title="Toollabel", - options=options, + img.paste(qrcode_img, (int((imagewidth[0] - (label_size - margin)) / 2), 10)) + txtstart = int((label_size - txtsize[2]) / 2) + draw.text( + (txtstart, int(label_size - 20 - font_size)), + self.name, + fill=self.BLACK, + font=font, ) + self.realprint(img, rotate="90", copies=int(self.copies)) def eigendomprint(self): img = Image.new("RGB", self.SMALL, self.WHITE) @@ -296,21 +239,15 @@ def eigendomprint(self): for mystep in steps[1:]: draw.text((650, mystep - 1), "☐", fill=self.BLACK, font=font) draw.text((651, mystep - 0), "☐", fill=self.BLACK, font=font) - img.save("data/output.jpg", "JPEG", dpi=(300, 300)) if self.large: - options = { - "copies": str(self.copies), - "page-ranges": "1", - "media": "media=custom_61.98x100mm_61.98x100mm", - } + scale = self.SMALL[0] / self.SMALL[1] + img = img.resize( + (int(self.SMALL[0] * scale), int(self.SMALL[1] * scale)), + Image.Resampling.LANCZOS, + ) + self.realprint(img, rotate="90", copies=int(self.copies)) else: - options = {"copies": str(self.copies), "page-ranges": "1"} - cups.Connection().printFile( # pylint: disable=no-member - self.printer, - "data/output.jpg", - title="Eigendom", - options=options, - ) + self.realprint(img, copies=int(self.copies)) def barcodenum(self, text): if text == "abort": diff --git a/tests/plugins/test_stickers.py b/tests/plugins/test_stickers.py index 59fdc65..e157e7d 100644 --- a/tests/plugins/test_stickers.py +++ b/tests/plugins/test_stickers.py @@ -256,7 +256,7 @@ def test_toollabel_flow(): sticky.master.donext.assert_called_with(sticky, "toolnum") -@patch("plugins.stickers.cups") +@patch("plugins.stickers.brother_ql.backends.helpers") def test_toolnum_prints_label(_cups): master = Mock() sticky = stickers("main", master) @@ -266,7 +266,7 @@ def test_toolnum_prints_label(_cups): assert sticky.copies == 1 -@patch("plugins.stickers.cups") +@patch("plugins.stickers.brother_ql.backends.helpers") def test_toolprint_binary_qrcode_path(_cups): sticky = stickers("main", Mock()) sticky.name = "tool-42" @@ -275,7 +275,7 @@ def test_toolprint_binary_qrcode_path(_cups): sticky.toolprint() -@patch("plugins.stickers.cups") +@patch("plugins.stickers.brother_ql.backends.helpers") def test_direct_print_methods_cover_binary_and_food_paths(_cups): master = Mock() sticky = stickers("main", master) @@ -355,7 +355,7 @@ def test_large_property_label_sets_large_flag(): sticky.master.donext.assert_called_with(sticky, "eigendomcount") -@patch("plugins.stickers.cups") +@patch("plugins.stickers.brother_ql.backends.helpers") def test_eigendomprint_large_options(_cups): sticky = stickers("main", Mock()) sticky.LOGOFILE = io.BytesIO( diff --git a/tests/plugins/test_undo.py b/tests/plugins/test_undo.py index 306b332..cc6d9ca 100644 --- a/tests/plugins/test_undo.py +++ b/tests/plugins/test_undo.py @@ -200,7 +200,10 @@ def test_undo_listundo(): { "special": "custom", "custom": [ - {"text": 123, "display": " \u20ac10.00 2011-03-13 08:08:43"} + { + "text": 123, + "display": "user \u20ac10.00 2011-03-13 08:08:43", + } ], "sort": "text", } From b8a167dc9338d81884bcba3425b132a92ba55821 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Tue, 19 May 2026 16:29:06 +0200 Subject: [PATCH 6/8] fixed some fixes --- Makefile | 2 -- requirements-dev.in | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Makefile b/Makefile index be3575c..764f249 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,6 @@ venv: .venv/make_venv_complete ## Create virtual environment ${PYTHON} -m venv .venv . .venv/bin/activate && ${env} pip install -U pip . .venv/bin/activate && ${env} pip install -U pip-tools - . .venv/bin/activate && ${env} ${PYTHON} -m piptools compile requirements.in - . .venv/bin/activate && ${env} ${PYTHON} -m piptools compile requirements-dev.in . .venv/bin/activate && ${env} pip install -Ur requirements.txt . .venv/bin/activate && ${env} pip install -Ur requirements-dev.txt touch .venv/make_venv_complete diff --git a/requirements-dev.in b/requirements-dev.in index 3551948..95ab9e6 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -1,3 +1,4 @@ +-c requirements.txt pytest pytest-cov black From ae94a2905a7c1bf48110d3a8cee21c1d5077ccc8 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 20 May 2026 10:21:56 +0200 Subject: [PATCH 7/8] dev txt added --- requirements-dev.txt | 62 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 requirements-dev.txt diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..3035131 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,62 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile requirements-dev.in +# +astroid==3.0.1 + # via pylint +black==23.11.0 + # via -r requirements-dev.in +build==1.0.3 + # via pip-tools +click==8.1.7 + # via + # -c requirements.txt + # black + # pip-tools +coverage[toml]==7.3.2 + # via pytest-cov +dill==0.3.7 + # via pylint +iniconfig==2.0.0 + # via pytest +isort==5.12.0 + # via pylint +mccabe==0.7.0 + # via pylint +mypy-extensions==1.0.0 + # via black +packaging==23.2 + # via + # black + # build + # pytest +pathspec==0.11.2 + # via black +pip-tools==7.3.0 + # via -r requirements-dev.in +platformdirs==4.1.0 + # via + # black + # pylint +pluggy==1.3.0 + # via pytest +pylint==3.0.2 + # via -r requirements-dev.in +pyproject-hooks==1.0.0 + # via build +pytest==7.4.3 + # via + # -r requirements-dev.in + # pytest-cov +pytest-cov==4.1.0 + # via -r requirements-dev.in +tomlkit==0.12.3 + # via pylint +wheel==0.42.0 + # via pip-tools + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools From 1fefb757e48d09ae3c18caf3e90029d675567799 Mon Sep 17 00:00:00 2001 From: Mendel Mobach Date: Wed, 20 May 2026 10:25:48 +0200 Subject: [PATCH 8/8] local time niet hardcoden --- tests/plugins/test_undo.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/plugins/test_undo.py b/tests/plugins/test_undo.py index cc6d9ca..be9a705 100644 --- a/tests/plugins/test_undo.py +++ b/tests/plugins/test_undo.py @@ -190,6 +190,9 @@ def test_undo_listundo(): master_mock = Mock() undo = undo_module.undo("SID", master_mock) undo.undo = {123: {"totals": {"user": 10}, "receipt": [], "beni": "text"}} + expected_time = undo_module.time.strftime( + "%Y-%m-%d %H:%M:%S", undo_module.time.localtime(123 + 1300000000) + ) undo.listundo() calls = [ @@ -202,7 +205,7 @@ def test_undo_listundo(): "custom": [ { "text": 123, - "display": "user \u20ac10.00 2011-03-13 08:08:43", + "display": f"user \u20ac10.00 {expected_time}", } ], "sort": "text",