()),
+ );
+ });
+}
diff --git a/billsplit/test/fixtures/sample_receipt.json b/billsplit/test/fixtures/sample_receipt.json
new file mode 100644
index 0000000..de6f402
--- /dev/null
+++ b/billsplit/test/fixtures/sample_receipt.json
@@ -0,0 +1 @@
+{"protoVersion":"000","IDZ":"c=068b8822a7d1459697c518bacdc26abf|g={b6b16726-f935-4e67-b756-bca881c12124}|s=5834|p=1|t=4320","deviceType":2,"printed":false,"data":"eyJhbGciOiJSUzI1NiIsIng1YyI6WyJNSUlGUVRDQ0F5bWdBd0lCQWdJRERxR0dNQTBHQ1NxR1NJYjNEUUVCQ3dVQU1JR2JNUXN3Q1FZRFZRUUdFd0pRVERFVU1CSUdBMVVFQ0F3TFRXRjZiM2RwWldOcmFXVXhFVEFQQmdOVkJBY01DRmRoY25ONllYZGhNU1F3SWdZRFZRUUxEQnREWlc1MGNuVnRJRU5sY25SNVptbHJZV05xYVNCbExVdGhjM2t4R3pBWkJnTlZCQW9NRWxCdmMyNWxkQ0JRYjJ4emEyRWdVeTVCTGpFZ01CNEdBMVVFQXd3WFVFOVRUa1ZVSUdVdFMyRnplU0JTYjI5MElFTkJJREV3SWhnUE1qQXlNekV4TWprd05qRXlNakZhR0E4eU1ETXpNVEV5T0RBMk1USXlNVm93T3pFTE1Ba0dBMVVFQmhNQ1VFd3hGakFVQmdOVkJBTVREVVZCV2pJeU1ESXhOakF4T1RZeEZEQVNCZ05WQkFvVEMxQnZjMjVsZENCVExrRXVNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXQrSUxlZlJWNjhqcFdDa2RveitZZWprRnU1dkh3N01vYjgxSFIyQnhqUVp6eENhN2drMjExTEs5T1J6Y2ZIcW1YeU5aV3c3U0tpcUp1U0ZhM1Z5R29CaDFFMmF6VWxvcU5Db3g0TFNaM0FwQ3U1d0NIOVFaaS94YW5nQ2pReUtybG44bFFZaWtkNENTYTJyYlQycWVUZytRckJEdHBreVpmc3hQTWF6aFFuek9MNTRWMlZqQ2ZWUzFTajRNQ3lHeHVwQnl5RFY3UzlaKzRPOEdqZ0JYL3RSa1pxRW1SQmpELy83N2o4cjJWTkIyTHlPUXM3alN6RE1zbFo0VUFFN1lzMm1WNHhnZmtLYlh5TUhQK013VjFoZGpMMDJzR3E4ckFUK25aWXFtdURQNGJZKzJiWG5LNzZWQk1uT0pLVEQ0M0NQamhEVXErYjJsd2k4RUZmSlNxUUlEQVFBQm80SG9NSUhsTUF3R0ExVWRFd0VCL3dRQ01BQXdIUVlEVlIwT0JCWUVGQkRiZUErelZVTCtQYnBXam5sVFNlcmlrN0FkTUI4R0ExVWRJd1FZTUJhQUZFNElhYlJWR0FHTFJkSGlwZVNaeldHQmo0MUpNQTRHQTFVZER3RUIvd1FFQXdJRjREQTFCZ05WSFI4RUxqQXNNQ3FnS0tBbWhpUm9kSFJ3T2k4dlpXc3VjRzl6Ym1WMExtTnZiUzV3YkM5amNtd3ZjbU5oTVM1amNtd3dUZ1lEVlIwZ0JFY3dSVEJEQmdzckJnRUVBWU9mYmdFQkFUQTBNRElHQ0NzR0FRVUZCd0lCRmlab2RIUndjem92TDJWckxuQnZjMjVsZEM1amIyMHVjR3d2WTNCekwzSmpZVEV1YUhSdGJEQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FnRUFrUk5nNEhuMXUrM2tzeEZ2SmFTMHplYnBqQ3pVb2hyYldTZ3dHaGZWTUk1ZnJaZG5Id0V0SytPL1FuT2FIZXdzblZ0am5Samx0NWIrWHA3bFZNVFdMYnpYNjRVTUZQZkhzVER1OER6Zml6NVZjRUJ0MkNnSTlacFVSNHgyR0VJTDlSQmJzTzRqanFZajh1YnA3OWdsckgxMjF6Y3BhNmhSYkpZOHFReHNSTnkxZkNTL3ppazlQTTFla214eWc5b3pxOUJ4WmxKMnREZW12Y0pxbUhMaWNTUCsxWkRZWGR2SnJiNVJHMmFWL2JLeFJmTm15NGZxUVRNSFlucC94K2FBRlByTFdjV3lDbUMya1FodnNFYVJ1cWZDdlJ0TEtsT0t0UC9zdzRxQzlXbVdzbG8zeXl4YW9WRkRsYk5NZ3lWWWpicmtWZ0U1SXBPRWJpUG1GZTY4cUNKMk5KL0tsNFNWUGJiN2RSWXJyeHdYYVAzN2VUSXJKQ3NwbUMrWC9oNzQ3ak5mYTQxeXd3OCsrb1BMblNpby9PcFpNT0IzZmtBaWdjODlnNEtRalJLNDFsOGRJQnYvRmpMZm9XM3JidW5YQTM4R0VKT1ZhY1ZYM3ZQcUNoWExVYTN3dHBQTWRWbm9NbkFRMUJCOEU4RUs1YUtrK0tyQ1NzVnVPdmE0dTlsbURPdXFaMGt1Snd1M2RhM0c2TUpzSm90cloyTWNpMTV0S215UkU1VVViYlhBcXN6a2hXZTZ2bUdMa0ZTemliemZ2TXZmQ0xoMkxvQTBDakNCMGJLVHFydUdsSUpGZ0NhNFpKTDRnTHBiV2d0K2lMZDg3UEZwdGtBMUVHSldHSXNSVFBzVnhLNFQvWE5MME5td3hzTHdPTHhXZ2JxK1hjNDlZNUhYb0MzNDRSND0iXSwianBrbWV0YWRhdGEiOiJleUpqYjNKeVpXeGhkR2x2Ymtsa0lqb2lWRVpFTGtWQldqSXlNREl4TmpBeE9UWXVNakF5TkMwd05pMHdOVlF3T0RveE1Eb3dNQzQyTkRGYUluMCIsImVQYXJhZ29uLm1mLmdvdi5wbCI6ImV5SjNaWEp6YW1FaU9pSktVRXRmUzBGVFFWOVFRVkpCUjA5T1gzWXlMVEFpTENKS1VFdEpSQ0k2SWpBd01UQXdNREF3TURBd01EWXhNREl4TXlJc0ltUmhkR0ZLVUVzaU9pSXlNREkyTFRBM0xUTXdWREUzT2pJeE9qRTVMakF3TUZvaUxDSktVRXRTUlVZaU9uc2lTbEJMU1VRaU9pSXdNREV3TURBd01EQXdNREEyTVRBeU1URWlMQ0pUU0VFeU5UWWlPaUl6TXpVNVFrRkVRa0kzTURSRE5UZzBNVVk1T1RBd01ERkdOREE1T1VGRVJUSkZSVVF6TUVFME4wRkZSa1JDT1RNd05rVTFRVUZEUlRoRk0wVTJOak00SW4xOSJ9.eyJkb2t1bWVudCI6eyJuYWdsb3dlayI6eyJ3ZXJzamEiOiJKUEtfS0FTQV9QQVJBR09OX3YyLTAiLCJkYXRhSlBLIjoiMjAyNi0wNy0zMFQxNzoyMToxOS4wMDBaIn0sInBvZG1pb3QxIjp7Ik5JUCI6Ijc3OTEwMTEzMjciLCJuYXp3YVBvZCI6IkpFUk9OSU1PIE1BUlRJTlMgUE9MU0tBIFMuQS4iLCJhZHJlc1BvZCI6eyJrb2RQb2N6dCI6IjM0LTMyNSIsIm1pZWpzYyI6IsWBT0RZR09XSUNFIiwidWxpY2EiOiJCRVNLSURaS0EgOCJ9LCJuclVuaWsiOiJFQVoyMjAyMTYwMTk2IiwibnJGYWJyIjoiUE8xMTQwNTQzOTEiLCJuckV3aWQiOiIyMDI0LzAwMDA5MjEyNiJ9LCJwYXJhZ29uIjp7IkpQS0lEIjo2MTAyMTMsInBhbWllY0NociI6MSwibnJEb2siOjYwODgyNywicG96eWNqYSI6W3sidG93YXIiOnsiYnJ1dHRvIjo1NDksImNlbmEiOjU0OSwiaWRTdFBUVSI6IkEiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IlRvbmljICAgICAgICAgICAgICAgICAgICBBIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6NTQ5LCJjZW5hIjo1NDksImlkU3RQVFUiOiJBIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJDaXRydXMgbWl4ICAgICAgICAgICAgICAgQSIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjE3OTgsImNlbmEiOjg5OSwiaWRTdFBUVSI6IkEiLCJpbG9zYyI6IjIiLCJuYXp3YSI6Ik5hcEdhekNvY2FDb01JWDIsNWwgICAgICBBIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6MTc5OCwiY2VuYSI6ODk5LCJpZFN0UFRVIjoiQSIsImlsb3NjIjoiMiIsIm5hendhIjoiTmFwR2F6Q29jYUNvTUlYMiw1bCAgICAgIEEiLCJvcGVyIjpmYWxzZX19LHsidG93YXIiOnsiYnJ1dHRvIjo0NDksImNlbmEiOjQ0OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjEiLCJuYXp3YSI6Ik9zaGVlVml0YW1NYWduZXoxLDFsICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6NzE0LCJjZW5hIjoxMTksImlkU3RQVFUiOiJBIiwiaWxvc2MiOiI2IiwibmF6d2EiOiJQb2xhcmlzIE5HYXogIDEsNWwgICAgICAgQSIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjE1OTYsImNlbmEiOjM5OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjQiLCJuYXp3YSI6IlRvc3QgTWHFm2xhbnkgNTAwZyAgICAgICAgQyIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjc5OTgsImNlbmEiOjUzOTksImlkU3RQVFUiOiJBIiwiaWxvc2MiOiIyIiwibmF6d2EiOiJXw7Nka2FCIEJvY2lhbiAwLDdsICAgICAgIEEiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMjgwMH19fSx7InRvd2FyIjp7ImJydXR0byI6OTk5LCJjZW5hIjo5OTksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJPbGVqUnplcFpPbGl3V3liMWwgICAgICAgQyIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjY0OSwiY2VuYSI6NjQ5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiR3Jhbm9sYU1sZWN6Q3plazM1MGcgICAgIEMiLCJvcGVyIjpmYWxzZX19LHsidG93YXIiOnsiYnJ1dHRvIjozOTksImNlbmEiOjM5OSwiaWRTdFBUVSI6IkEiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IlBvZHBhxYJOYXR1cjY0S29zdGtpICAgICAgQSIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjk5OSwiY2VuYSI6OTk5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiR3Jhbm9sYUZpdE5lc09hdDMwMGcgICAgIEMiLCJvcGVyIjpmYWxzZX19LHsidG93YXIiOnsiYnJ1dHRvIjo5OTksImNlbmEiOjk5OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IkdyYW5vbGFGaXROZXNRdWkzMDBnICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6Nzk4LCJjZW5hIjozOTksImlkU3RQVFUiOiJBIiwiaWxvc2MiOiIyIiwibmF6d2EiOiJETyBNT0pFSiBJRMSYICAgICAgICAgICAgIEEiLCJvcGVyIjpmYWxzZX19LHsidG93YXIiOnsiYnJ1dHRvIjoyMTY5LCJjZW5hIjozMjkwLCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMCw4NzEiLCJuYXp3YSI6IktpZcWCYkJyb2NrYVNva2_FgmtnICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotNjk3fX19LHsidG93YXIiOnsiYnJ1dHRvIjoyMDcyLCJjZW5hIjozMjkwLCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMCw4MzIiLCJuYXp3YSI6IktpZcWCYkJyb2NrYVNva2_FgmtnICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotNjY1fX19LHsidG93YXIiOnsiYnJ1dHRvIjozNzQ1LCJjZW5hIjo3NDksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiI1IiwibmF6d2EiOiJHdWFuY2lhbGVDdWJHQjE1MGcgICAgICAgQyIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjExMzcsImNlbmEiOjM3OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjMiLCJuYXp3YSI6IkJhZ2lldGthIDE3NWcgICAgICAgICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6NTk5LCJjZW5hIjo1OTksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJNYXNsb0Vrc3RyYTIwMGcgICAgICAgICAgQyIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjc0OSwiY2VuYSI6NzQ5LCJpZFN0UFRVIjoiQSIsImlsb3NjIjoiMSIsIm5hendhIjoiTmFwR2F6S2lubGV5TWl4MWwgICAgICAgIEEiLCJvcGVyIjpmYWxzZX19LHsidG93YXIiOnsiYnJ1dHRvIjo0OTksImNlbmEiOjQ5OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IkNlYnVsYSAxa2cgb3BhayAgICAgICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6NzM4LCJjZW5hIjozNjksImlkU3RQVFUiOiJCIiwiaWxvc2MiOiIyIiwibmF6d2EiOiJNdXN6U2FyS2FtMTg1ZyAgICAgICAgICAgQiIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjI5OSwiY2VuYSI6Mjk5LCJpZFN0UFRVIjoiQiIsImlsb3NjIjoiMSIsIm5hendhIjoiRElQIE9SSUVOVEFMTlkgQ1pPU04gICAgIEIiLCJvcGVyIjpmYWxzZX19LHsidG93YXIiOnsiYnJ1dHRvIjoyOTksImNlbmEiOjI5OSwiaWRTdFBUVSI6IkIiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IlByenlwRG9Tb0FtZXJLYW1pMjBnICAgICBCIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6MTI5OSwiY2VuYSI6MTI5OSwiaWRTdFBUVSI6IkIiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IktldFBpa2FudG55UHVkIDk5MCBnICAgICBCIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6MTA5OSwiY2VuYSI6MTA5OSwiaWRTdFBUVSI6IkIiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IlNvc0N6b3NNYWRYWEw4NjBnICAgICAgICBCIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6MTI5LCJjZW5hIjoxMjksImlkU3RQVFUiOiJBIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJaYXBhbG5pY3prU29sZWJvMXN6dCAgICAgQSIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjg0OSwiY2VuYSI6ODQ5LCJpZFN0UFRVIjoiQSIsImlsb3NjIjoiMSIsIm5hendhIjoiUsSZY3puaWsgUXVlZW4gMiA0ICAgICAgICBBIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6Mjc5OCwiY2VuYSI6MTM5OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjIiLCJuYXp3YSI6IkphamEgVyBXeWIgTCAxMHN6dCAgICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6MTI5NywiY2VuYSI6MjE5OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjAsOTI3IiwibmF6d2EiOiJNacSZc1pub2dpS3UgYiBrIGtnICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotNzQxfX19LHsidG93YXIiOnsiYnJ1dHRvIjoyOTg5LCJjZW5hIjoyOTg5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiU2VyIEdvdWRhIMWad2lhdDFrZyAgICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6NTQ5LCJjZW5hIjo1NDksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJNaXhTYcWCYXRSb3N6cG9WZjE1MGcgICAgIEMiLCJvcGVyIjpmYWxzZX19LHsidG93YXIiOnsiYnJ1dHRvIjozOTgsImNlbmEiOjE5OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjIiLCJuYXp3YSI6IkxpbW9ua2Egc3p0ICAgICAgICAgICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6NTk5LCJjZW5hIjo1OTksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJDeXRyeW5hIDUwMCBnICAgICAgICAgICAgQyIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjEyOTksImNlbmEiOjEyOTksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJQb21pZG9yUGFwcnljejUwMGcgICAgICAgQyIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjU5OCwiY2VuYSI6Njk5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMCw4NTYiLCJuYXp3YSI6IkJhbmFuIEx1eiAgICAgICAgICAgICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6MzQwLCJjZW5hIjo4OTksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIwLDM3OCIsIm5hendhIjoiT2fDs3Jla1N6a0x1eiAgICAgICAgICAgICBDIiwib3BlciI6ZmFsc2V9fSx7InRvd2FyIjp7ImJydXR0byI6MTQ5OCwiY2VuYSI6MTQ5OCwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IkJvcsOzd2thQW1lcnlrIDMwMGcgICAgICAgQyIsIm9wZXIiOmZhbHNlfX0seyJ0b3dhciI6eyJicnV0dG8iOjU1NiwiY2VuYSI6Nzk5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiQ2hpcHN5Q3IxMzAtMTQwZyAgICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMjQzfX19LHsidG93YXIiOnsiYnJ1dHRvIjozNDUsImNlbmEiOjQ5NSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IkNoaXBzeVRvcEZyb21hZ2UxNTBnICAgICBDIiwib3BlciI6ZmFsc2UsInJhYmF0Ijp7Im9waXMiOiIxIiwid2FydCI6LTE1MH19fSx7InRvd2FyIjp7ImJydXR0byI6NjA1LCJjZW5hIjo4NjksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIxIiwibmF6d2EiOiJDaGlwc3lMYXkgczEyMGcgICAgICAgICAgQyIsIm9wZXIiOmZhbHNlLCJyYWJhdCI6eyJvcGlzIjoiMSIsIndhcnQiOi0yNjR9fX0seyJ0b3dhciI6eyJicnV0dG8iOjU1NywiY2VuYSI6Nzk5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiQ2hpcHN5Q3J1bjEzMC0xNDBnICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMjQyfX19LHsidG93YXIiOnsiYnJ1dHRvIjozNDcsImNlbmEiOjQ5OSwiaWRTdFBUVSI6IkMiLCJpbG9zYyI6IjEiLCJuYXp3YSI6IlByYcW8eW5raU5pZXRLZXQxMDBnICAgICAgQyIsIm9wZXIiOmZhbHNlLCJyYWJhdCI6eyJvcGlzIjoiMSIsIndhcnQiOi0xNTJ9fX0seyJ0b3dhciI6eyJicnV0dG8iOjU1NiwiY2VuYSI6Nzk5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiQ2hpcHlDcnVuMTMwLTE0MGcgICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMjQzfX19LHsidG93YXIiOnsiYnJ1dHRvIjoxMzk5LCJjZW5hIjoxNjk5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiUGl6emFLdXJQaWVjejYwMGcgICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMzAwfX19LHsidG93YXIiOnsiYnJ1dHRvIjoxMzk5LCJjZW5hIjoxNjk5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMSIsIm5hendhIjoiUGl6Um9kelN6eW5TZXI2MDBnICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMzAwfX19LHsidG93YXIiOnsiYnJ1dHRvIjoxMzk4LCJjZW5hIjo2OTksImlkU3RQVFUiOiJDIiwiaWxvc2MiOiIzIiwibmF6d2EiOiJTenlua2FLb25zZXJ3b0tXMjAwICAgICAgQyIsIm9wZXIiOmZhbHNlLCJyYWJhdCI6eyJvcGlzIjoiMSIsIndhcnQiOi02OTl9fX0seyJ0b3dhciI6eyJicnV0dG8iOjY3OCwiY2VuYSI6MzM5LCJpZFN0UFRVIjoiQyIsImlsb3NjIjoiMyIsIm5hendhIjoiTWFrYXJvblBlbm5lUGE1MDBnICAgICAgIEMiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMzM5fX19LHsidG93YXIiOnsiYnJ1dHRvIjoyODc0LCJjZW5hIjo0NzksImlkU3RQVFUiOiJBIiwiaWxvc2MiOiIxMiIsIm5hendhIjoiUElXTyBIRUlORUtFTiAwLDVCICAgICAgIEEiLCJvcGVyIjpmYWxzZSwicmFiYXQiOnsib3BpcyI6IjEiLCJ3YXJ0IjotMjg3NH19fV0sInN0UFRVIjpbeyJpZCI6IkEiLCJ3YXJ0IjoyMzAwfSx7ImlkIjoiQiIsIndhcnQiOjgwMH0seyJpZCI6IkMiLCJ3YXJ0Ijo1MDB9LHsiaWQiOiJEIiwid2FydCI6MH0seyJpZCI6IkUiLCJ3YXJ0IjoiWlcifSx7ImlkIjoiRiIsIndhcnQiOjE3MDB9LHsiaWQiOiJHIiwid2FydCI6MH1dLCJwb2RzdW0iOnsid2FsdXRhIjoiUExOIiwic3VtYUJydXR0byI6NTkwNTUsInN1bWFQb2QiOjU1ODgsInN1bWFPcHVzdCI6LTEwNzA5LCJzdW1hTmV0dG8iOlt7ImlkU3RQVFUiOiJBIiwiYnJ1dHRvIjoxOTIwNCwidmF0IjozNTkxfSx7ImlkU3RQVFUiOiJCIiwiYnJ1dHRvIjozNzM0LCJ2YXQiOjI3N30seyJpZFN0UFRVIjoiQyIsImJydXR0byI6MzYxMTcsInZhdCI6MTcyMH1dfSwidG90YWwiOnsiemFwbFp3cm90Ijo1OTc1NX0sIm9wYWsiOnsiZGFuZU9wYWsiOlt7ImNlbmEiOjUwLCJpbG9zYyI6MTQwMDAsIm5hendhIjoiQnV0IFBsYXN0aWsga2F1Y2phIn1dLCJ3YXJ0Ijo3MDB9LCJwbGF0bm9zYyI6W3sicmVzenRhIjpmYWxzZSwiZm9ybWEiOiIyIiwid2FydCI6NTk3NTUsIm5hendhIjoiVklTQSBDQVJEIDA3IDEifV0sInpha1NwcnplZCI6IjIwMjYtMDctMzBUMTc6MjE6MTkuMDAwWiIsIm5yS2FzeSI6Ikthc2EgMSIsImthc2plciI6Ikthc2plciBuciAyNyIsIm5yUGFyYWciOjQxOSwiZ3JhZmlrYSI6MX19fQ.mISQbxs3V0GxEIPLaUviW3n2bgAewkMHOV3dmMo64RFMBXPQin3nMg5BUtX_irNEAjaKqpfHJTXy9sxDncqgo1KbynblPschfOeZWuJmY4wyBHjLkdEJYcqAATGBorl4Awo2kTAoSK0_fiRD6EkOk5ooMS5CiSDueV2t-AJW4POmuhq_KNX7TXyxqGDvKcrFHe2hPoGx4cmayC59ymDJKWcSOyStFkUWm_hpui2IObLmXgPxCj8KjTVtWA6_Qsq8JDLuAcAyCzWvi23AFbCelDUo846IUVe9m-DSUCgiuDDCMLmYCBnul0Q049tY5kMc2IhOOCl5F_Qy9SHTi_k4SQ","header":[{"image":{"id":"EAZ2202160196-1-445785","hash":"DmwwvSQRHLaIxnrFwFSH2ZMvlLZHRrslUP5n/gwzXpU=","data":"Qk02FwAAAAAAAD4AAAAoAAAAMAEAAJMAAAABAAEAAAAAAPgWAAAjLgAAIy4AAAAAAAAAAAAAAAAAAP///wD//////////////////////////////////////////////////wAA//////////////////////////////////////////////////8AAP//////////////////////////////////////////////////AAD//////////////////////////////////////////////////wAA//////////////////////////////////////////////////8AAP//////////////////////////////////////////////////AAD//////////////////////////////////////////////////wAA//////////////////////////////////////////////////8AAP//////////////////////////////////////////////////AAD//////////////////////////////////////////////////wAA//////wAAf////////////////////////////////////////8AAP/////AAAAf////////////////////////////////////////AAD////+AAAAA////////////////////////////////////////wAA////+ABVVAD///////////////////////////////////////8AAP///+AAAAAAD///////////////////////////////////////AAD///+AVaqqqAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf/////wAA////AAAAAAAFVVVVVVVaqqqqqqqqqqqqVVVVVVVVVVVVQD////8AAP///wqqVVVVQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH////AAD///8AAAAAAAAqqqqqqqVVVVVVVVVVVVWqqqqqqqqqqqqlUf///wAA////FVJUqqqoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB///8AAP///wAFqgAAABVVVVVVWqqqqqqqqqqqqlVVVVVVVVVVVVqqn///AAD//x8KqlVAFVUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//wAA//4fFVWqAAAAA////////////////////////////////VVX//8AAP/8HwqqVAACqoFVVVVVWqqqqqqqqqqqqlVVVVVVVVVVVVqAAP//AAD//A8VVagAAAAB////////////////////////////////+qp//wAA//gPCqpQAAVVUKqqqqqlVVVVVVVVVVVVqqqqqqqqqqqqpVYAP/8AAP/wDxVVoAACAAB/////////////////////////////////1V//AAD/4IYKqlAAAVKoVVVVVVqqqqqqqqqqqqpVVVVVVVVVVVVaqqAP/wAA/8EAFVWgAAKoAD/////////////////////////////////6p/8AAP/CgAqqUAABVVUKqqqqpVVVVVVVVVVVVaqqqqqqqqqqqqVVUAP/AAD/hUAVVagAAqoAH///////////////////////////////x/1V/wAA/wqACqpUAAVVKoVVVVVaqqqqqqqqqqqqVVVVVVVVVVVVWqKqAf8AAP8FQBVVqgAKqoAH//////////H////8f///x///x/8fD//j/6r/AAD/CoAKqlVAVVUBQqqqD4FgEBjAYxjEcBWjEYOYxwGqCgY40VUA/wAA/hVAFVWqqqqqgAf//AcAwDAYxmMYzGMf4xmZmMYx/ExmePH/1X8AAP4VQBVVqqqqqoAH//wHAMAwGMZjGMxjH+MZmZjGMfxMZnjx/9V/AAD+KqAKqlVVVVVAoVVYZjxGKOiP4xjEY+pjEXkBRn9U+P44oKqAfwAA/BVABVWqqqqqgAP/+P48Rjx4gGMYzGAf4xnhh8YB/PgGeOD/6r8AAPwqoAqqVVVVVUBQqrGqPEY2KI5jGMRjlaMRgoPGOajs5jjEVUA/AAD4VVAFVaqqqqqAAf/x/jzGPzjOYxjMc5/jGY+Rxzn8TOZ4jH/1XwAA+CqgCqpVVVVVQAhVUVUAoDAIwKAoBHAqYBFBGMcDVgYOAIoqoB8AAPhVUAVVqqqqqqAA//H/gfEwH+DgeB/4P+B/45//h/8fHgP///qvAADwqqgCqlVVVVVAFKqouqVVNVVVVVVVVVWqqqqaqqqqqqqlVVVQDwAA8VVQBVWqqqqqoAD/+Gf//z///////v//////n////////////U8AAPCKqAKqVVVVVVAIVVQFWqoqqKqqqqxqqlVRVRVFVVVVVVqqqqgHAADxFVQFVaqqqqqoAH/+D///P/j////8f///+f+fx//////////+pwAA4AqoAqpVFVVVVAQqqqqlVVVVVVVVVFVVqqiqqqqqqqqqpVVVVAcAAOAFVAFVqAKqqqoIf/////////////////////////////////9TAADiCqoCqlAAVVVUBFVVVVqqqqqqqqqqqqpVVVVVVVVVVVVaqqqqAwAA4AVUAVWgACqqqqp//////////////////////////////////qsAAOIKqgCqQABVVVVUKqgABVAVwo1UIQNBVawA6wKoLAqgKoIQVVUDAADgBVUBVYAAKqqqqj/4AAP4PwAD+AADwf/wAD8D+DwPwD8AAH//UwAA4gqqAKoAABVVVVQVWqqkqSpVU6VVUtWqaqqVKVK0pSpUpVVqqgMAAOAFVQFVgAAqqqqqP/gAAPg+AAHgAAPB/8AADwP4PA8A/AAAf/+rAADiCqqAqgAAFVVVVCqpX1patKVBSoKrSVXVAUtSqS1KVKlH6lVVAwAA4AVVQFWAACqqqqo/+BuAeDwf/8B+A8H/gPwHA/g8DAP4HqB//1MAAOIKqoAqAAAVVVVUFVq/5WkpWqrV1VLVq6uqpSlStKKlUr/1aqoDAADhFVVAVYAAKqqqqj/4FUB4OB//wP8Dwf+B/gcD+DwAD/AlUH//qwAA4rqqoCoAABVVVVQqqV/aWrqgAMqrK0tVlVdXUqktVUq1f/pVVQMAAOF1VUAVgAAqqqqqf/gawHg4AADA/wPB/4H+BwP4PAAf8Dqwf/9TAADguqqgKkAAVVVVVFVav6VpKVVU1dVS1KurqqWpUrSipVK/9WqqAwAA4X1VUBWgAKqqqqp/+AAA+DwfwMB+A8B/gPwHA/A8CAf4HWB//qcAAPC+qqgKUAFVVVVUKqlVWVq0uulKnqtKlZVVS1SlLUlSqUfqVVQHAADxfUFUBagCqqqCqH/4AAf4PA+B4AADwAHAAA8AADwOAfwAAH/9VwAA8L6AKApVVVVUAVRVWqqiqSpVUqVUUtRUSqqVKKq0pSpUpUVqqA8AAPBfABQFqqqqqgKo//gcA/g/AAf4AQPBgfAAPwIAfA+AfgAQf/qvAAD4vgAKAlVVVVQBUKqpX1lQFQqFVKErQUGsAOsCAK1KgCsAMFVQDwAA+F4ABQGqqqqoAqj/+BUB///gP/8PA//////////8D///////9V8AAPgsAAKCVVVVVAFRVVq/paqqqqqqqlKqqlVVVVVVVKVVVVqqqqAfAAD8XAAFAaqqqqgCof/4GwH///////8D//////////wP///////qvwAA/CwAAoBVVVVUAVKqqV9ZVFVVVVVVK1VVqqqqqqqtSqqqpVVVQD8AAP4UAAFAKqqqqAKj//gAA/g//////wP//////////A///////9U/AAD+CgACoBVVVVQFRVVaqqK1WqqqqqpSqqpVVVVVVVSlVVVaqqqAfwAA/hYAAVAqqqqqAoP/+AAH8B//////A//////////8D///////qn8AAP8LAAKgFVVVVRUCqqgABVKVVVVVVStVVaqqqqqqrAqqqqVVVQD/AAD/BYAFUAqqqqqqj//////4P/////+H//////////////////9U/wAA/4KAAqgFVVVQFRVVVVVaqqqqqqqqqqqqVVVVVVVVVVVVWqqoAf8AAP+BQAVUAqqoAAIf////////////////////////////////+qv/AAD/wrAKqgFVUAAAKqqqqqVVVVVVVVVVVVWqqqqqqqqqqqqlVVAH/wAA/+FcFVWAqoAAAA/////////////////////////////////1T/8AAP/wr+qqQFUB//wFVVVVWqqqqqqqqqqqqlVVVVVVVVVVVVqqgB//AAD/+Ff1VaAoB///gf///////////////////////////////qq//wAA//wr+qpAUB/4D8CqqqqlVVVVVVVVVVVVqqqqqqqqqqqqpVQAf/8AAP/+Ff1VoCB/gAHwf//////////////////////////////VVP//AAD//wqCqlAA/wAAeBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH//wAA//+FAFWoAfwAADwFVVVaqqqqqqqqqqqqVVVVVVVVVVVVWqqn//8AAP//wIAKVAP4AAAeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///AAD//+BAD6oH8AAAD4KqqqVVVVVVVVVVVVWqqqqqqqqqqqqlVD///wAA///4IAfVD/AAAAeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH///8AAP///BQH+h/gAAADwVVVWqqqqqqqqqqqqlVVVVVVVVVVVVqH////AAD///4Cq/wfwAAAA+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf////wAA////AFWoP8AH/AHg//////////////////////////////////8AAP///8AKVH+A///g8P//////////////////////////////////AAD////wACh8g/////B//////////////////////////////////wAA/////AAA/g/////4f/////////////////////////////////8AAP/////gAP8f///B+H//////////////////////////////////AAD//////gH/j///gPw//////////////////////////////////wAA///////h/+///5j8P/////////////////////////////////8AAP//////4f////+I/D//////////////////////////////////AAD//////+H///H/w/4//////////////////////////////////wAA///////D///h///+H/////////////////////////////////8AAP//////w///wP///h//////////////////////////////////AAD//////8P//4D///4f/////////////////////////////////wAA///////D//+A///+H/////////////////////////////////8AAP//////w///AP///h//////////////////////////////////AAD//////8P//wH/+H4f/////////////////////////////////wAA///////D//8B//B+H/////////////////////////////////8AAP//////4//+Af/gfh//////////////////////////////////AAD//////+P//gH/4H4f/////////////////////////////////wAA///////h//4D/+B+P/////////////////////////////////8AAP//////4f/+A//AfD//////////////////////////////////AAD//////+H//gf/wPw//////////////////////////////////wAA///////h//+H/8D8P/////////////////////////////////8AAP//////8P//D/+B+H//////////////////////////////////AAD///////D//x//gfh//////////////////////////////////wAA///////4f////6Pwf/////////////////////////////////8AAP//////+H/////H8P//////////////////////////////////AAD///////g/////h+D//////////////////////////////////wAA///////4H////9/A//////////////////////////////////8AAP///////B//////wf//////////////////////////////////AAD///////4P/////4P//////////////////////////////////wAA///////+B/////8D//////////////////////////////////8AAP///////wP////+B///////////////////////////////////AAD///////+B/////A///////////////////////////////////wAA////////AP////gf//////////////////////////////////8AAP///////gA////gP///////////////////////////////////AAD///////wYD///gH///////////////////////////////////wAA///////8HAH//gH///////////////////////////////////8AAP//////+D4Af/AD////////////////////////////////////AAD///////B/AAAAB////////////////////////////////////wAA///////g/8AAAB////////////////////////////////////8AAP/////wAf/wAAB/////////////////////////////////////AAD/////4Af//AAf/////////////////////////////////////wAA/////+Af//wf//////////////////////////////////////8AAP/////gP//+H///////////////////////////////////////AAD/////8H///h///////////////////////////////////////wAA//////////8f//////////////////////////////////////8AAP//////////D///////////////////////////////////////AAD//////////4f//////////////////////////////////////wAA///////////D//////////////////////////////////////8AAP//////////4MP/////////////////////////////////////AAD///////////AB/////////////////////////////////////wAA///////////8Af////////////////////////////////////8AAP///////////4H/////////////////////////////////////AAD////////////D/////////////////////////////////////wAA//////////////////////////////////////////////////8AAP//////////////////////////////////////////////////AAA="}},{"headerText":{"headerTextLines":"BIEDRONKA "CODZIENNIE NISKIE CENY" 5834
34-325 ŁODYGOWICE UL. BESKIDZKA 8
JERONIMO MARTINS POLSKA S.A.
62-025 KOSTRZYN UL.ŻNIWNA 5
"}},{"headerData":{"tin":"7791011327","docNumber":608827,"date":"2026-07-30T17:21:19.000Z","CPS":56}}],"body":[{"sellLine":{"name":"Tonic A","vatId":"A","price":549,"total":549,"quantity":"1","isStorno":false}},{"sellLine":{"name":"Citrus mix A","vatId":"A","price":549,"total":549,"quantity":"1","isStorno":false}},{"sellLine":{"name":"NapGazCocaCoMIX2,5l A","vatId":"A","price":899,"total":1798,"quantity":"2","isStorno":false}},{"sellLine":{"name":"NapGazCocaCoMIX2,5l A","vatId":"A","price":899,"total":1798,"quantity":"2","isStorno":false}},{"sellLine":{"name":"OsheeVitamMagnez1,1l C","vatId":"C","price":449,"total":449,"quantity":"1","isStorno":false}},{"sellLine":{"name":"Polaris NGaz 1,5l A","vatId":"A","price":119,"total":714,"quantity":"6","isStorno":false}},{"sellLine":{"name":"Tost Maślany 500g C","vatId":"C","price":399,"total":1596,"quantity":"4","isStorno":false}},{"sellLine":{"name":"WódkaB Bocian 0,7l A","vatId":"A","price":5399,"total":10798,"quantity":"2","isStorno":false}},{"discountLine":{"base":10798,"value":2800,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"A"}},{"sellLine":{"name":"OlejRzepZOliwWyb1l C","vatId":"C","price":999,"total":999,"quantity":"1","isStorno":false}},{"sellLine":{"name":"GranolaMleczCzek350g C","vatId":"C","price":649,"total":649,"quantity":"1","isStorno":false}},{"sellLine":{"name":"PodpałNatur64Kostki A","vatId":"A","price":399,"total":399,"quantity":"1","isStorno":false}},{"sellLine":{"name":"GranolaFitNesOat300g C","vatId":"C","price":999,"total":999,"quantity":"1","isStorno":false}},{"sellLine":{"name":"GranolaFitNesQui300g C","vatId":"C","price":999,"total":999,"quantity":"1","isStorno":false}},{"sellLine":{"name":"DO MOJEJ IDĘ A","vatId":"A","price":399,"total":798,"quantity":"2","isStorno":false}},{"sellLine":{"name":"KiełbBrockaSokołkg C","vatId":"C","price":3290,"total":2866,"quantity":"0,871","isStorno":false}},{"discountLine":{"base":2866,"value":697,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"KiełbBrockaSokołkg C","vatId":"C","price":3290,"total":2737,"quantity":"0,832","isStorno":false}},{"discountLine":{"base":2737,"value":665,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"GuancialeCubGB150g C","vatId":"C","price":749,"total":3745,"quantity":"5","isStorno":false}},{"sellLine":{"name":"Bagietka 175g C","vatId":"C","price":379,"total":1137,"quantity":"3","isStorno":false}},{"sellLine":{"name":"MasloEkstra200g C","vatId":"C","price":599,"total":599,"quantity":"1","isStorno":false}},{"sellLine":{"name":"NapGazKinleyMix1l A","vatId":"A","price":749,"total":749,"quantity":"1","isStorno":false}},{"sellLine":{"name":"Cebula 1kg opak C","vatId":"C","price":499,"total":499,"quantity":"1","isStorno":false}},{"sellLine":{"name":"MuszSarKam185g B","vatId":"B","price":369,"total":738,"quantity":"2","isStorno":false}},{"sellLine":{"name":"DIP ORIENTALNY CZOSN B","vatId":"B","price":299,"total":299,"quantity":"1","isStorno":false}},{"sellLine":{"name":"PrzypDoSoAmerKami20g B","vatId":"B","price":299,"total":299,"quantity":"1","isStorno":false}},{"sellLine":{"name":"KetPikantnyPud 990 g B","vatId":"B","price":1299,"total":1299,"quantity":"1","isStorno":false}},{"sellLine":{"name":"SosCzosMadXXL860g B","vatId":"B","price":1099,"total":1099,"quantity":"1","isStorno":false}},{"sellLine":{"name":"ZapalniczkSolebo1szt A","vatId":"A","price":129,"total":129,"quantity":"1","isStorno":false}},{"sellLine":{"name":"Ręcznik Queen 2 4 A","vatId":"A","price":849,"total":849,"quantity":"1","isStorno":false}},{"sellLine":{"name":"Jaja W Wyb L 10szt C","vatId":"C","price":1399,"total":2798,"quantity":"2","isStorno":false}},{"sellLine":{"name":"MięsZnogiKu b k kg C","vatId":"C","price":2199,"total":2038,"quantity":"0,927","isStorno":false}},{"discountLine":{"base":2038,"value":741,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"Ser Gouda Świat1kg C","vatId":"C","price":2989,"total":2989,"quantity":"1","isStorno":false}},{"sellLine":{"name":"MixSałatRoszpoVf150g C","vatId":"C","price":549,"total":549,"quantity":"1","isStorno":false}},{"sellLine":{"name":"Limonka szt C","vatId":"C","price":199,"total":398,"quantity":"2","isStorno":false}},{"sellLine":{"name":"Cytryna 500 g C","vatId":"C","price":599,"total":599,"quantity":"1","isStorno":false}},{"sellLine":{"name":"PomidorPaprycz500g C","vatId":"C","price":1299,"total":1299,"quantity":"1","isStorno":false}},{"sellLine":{"name":"Banan Luz C","vatId":"C","price":699,"total":598,"quantity":"0,856","isStorno":false}},{"sellLine":{"name":"OgórekSzkLuz C","vatId":"C","price":899,"total":340,"quantity":"0,378","isStorno":false}},{"sellLine":{"name":"BorówkaAmeryk 300g C","vatId":"C","price":1498,"total":1498,"quantity":"1","isStorno":false}},{"sellLine":{"name":"ChipsyCr130-140g C","vatId":"C","price":799,"total":799,"quantity":"1","isStorno":false}},{"discountLine":{"base":799,"value":243,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"ChipsyTopFromage150g C","vatId":"C","price":495,"total":495,"quantity":"1","isStorno":false}},{"discountLine":{"base":495,"value":150,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"ChipsyLay s120g C","vatId":"C","price":869,"total":869,"quantity":"1","isStorno":false}},{"discountLine":{"base":869,"value":264,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"ChipsyCrun130-140g C","vatId":"C","price":799,"total":799,"quantity":"1","isStorno":false}},{"discountLine":{"base":799,"value":242,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"PrażynkiNietKet100g C","vatId":"C","price":499,"total":499,"quantity":"1","isStorno":false}},{"discountLine":{"base":499,"value":152,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"ChipyCrun130-140g C","vatId":"C","price":799,"total":799,"quantity":"1","isStorno":false}},{"discountLine":{"base":799,"value":243,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"PizzaKurPiecz600g C","vatId":"C","price":1699,"total":1699,"quantity":"1","isStorno":false}},{"discountLine":{"base":1699,"value":300,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"PizRodzSzynSer600g C","vatId":"C","price":1699,"total":1699,"quantity":"1","isStorno":false}},{"discountLine":{"base":1699,"value":300,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"SzynkaKonserwoKW200 C","vatId":"C","price":699,"total":2097,"quantity":"3","isStorno":false}},{"discountLine":{"base":2097,"value":699,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"MakaronPennePa500g C","vatId":"C","price":339,"total":1017,"quantity":"3","isStorno":false}},{"discountLine":{"base":1017,"value":339,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"C"}},{"sellLine":{"name":"PIWO HEINEKEN 0,5B A","vatId":"A","price":479,"total":5748,"quantity":"12","isStorno":false}},{"discountLine":{"base":5748,"value":2874,"isDiscount":true,"isPercent":false,"isStorno":false,"vatId":"A"}},{"discountSummary":{"discounts":10709}},{"vatSummary":{"currency":"PLN","vatRatesSummary":[{"vatId":"A","vatRate":2300,"vatSale":19204,"vatAmount":3591},{"vatId":"B","vatRate":800,"vatSale":3734,"vatAmount":277},{"vatId":"C","vatRate":500,"vatSale":36117,"vatAmount":1720}]}},{"sumInCurrency":{"fiscalTotal":59055,"totalWithPacks":59755,"currency":"PLN","printBig":true,"printable":true}},{"section":{"type":0}},{"section":{"type":1}},{"pack":{"name":"But Plastik kaucja","price":50,"quantity":"14","total":700,"isNegative":false}},{"section":{"type":100,"amount":700,"currency":"PLN"}},{"section":{"type":101,"amount":59755,"currency":"PLN"}},{"section":{"type":3}},{"payment":{"type":"2","amount":59755,"name":"VISA CARD 07 1","currency":"PLN"}},{"fiscalFooter":{"billNumber":419,"uniqueNumber":"EAZ2202160196","cashNumber":"Kasa 1","cashier":"Kasjer nr 27","CPS":56,"date":"2026-07-30T17:21:19.000Z"}},{"addLine":{"id":0,"data":"Nr transakcji:4320
","width":80,"CPS":56}},{"addLine":{"id":30,"data":"Numer:5834260730432001
","width":80,"CPS":56}},{"addLine":{"id":41,"data":"Udzielono łącznie opustów 107,09
","width":80,"CPS":56}},{"addLine":{"id":41,"data":"w tym:
","width":80,"CPS":56}},{"addLine":{"id":41,"data":" Promocje 107,09
","width":80,"CPS":56}},{"addLine":{"id":6,"data":"Numer karty:99001*****936
","width":80,"CPS":56}},{"barcode":{"id":5,"data":"MTAwMDA1ODM0NDMyMDI0MDIwMTgxOA=="}},{"sysNumber":{"data":"4320","width":80,"CPS":56}}],"sign":"9884906F1B375741B11083CB694BE25B79F66E001EC24307395DDD98CA3AE1114C0573D08A7DE7320E4152D5FF8AB34402368AAA97C72535F2F6CC439DCAA0A3529BCA76E53EC7217CE7995AE266638C320478CB91D10961CA80013181A2B978030A3691302848AD3F7E2443E8490E939A28312E428920EE795DADF80256E0F3A6BA1ABF28D5FB4D7CB1A860EF29CAC51DEDA13E81B1E1C99AC82E7DCA60C92967123B24AD1645169BF869BA2D8839B2E65E03F10A3F0A8D356D580EBF42CABC2432EE01C0320B35AF8B6DC015B09E943528F38E885157BD9BE0D2502822B830C230B9980819EE974434E3DB58E6431CD8884E38297917F432F521D38BF93849"}
diff --git a/billsplit/test/helpers.dart b/billsplit/test/helpers.dart
new file mode 100644
index 0000000..d341bb1
--- /dev/null
+++ b/billsplit/test/helpers.dart
@@ -0,0 +1,118 @@
+import 'dart:io';
+
+import 'package:billsplit/state/app_state.dart';
+import 'package:billsplit/ui/receipt_screen.dart';
+import 'package:file_selector_platform_interface/file_selector_platform_interface.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:provider/provider.dart';
+
+const _pathChannel = MethodChannel('plugins.flutter.io/path_provider');
+
+/// Fake [FileSelectorPlatform] driving open/save dialogs in tests.
+///
+/// file_selector is swapped at the platform-interface level rather than at the
+/// method channel: the real implementation talks Pigeon, so a MethodChannel
+/// mock would not intercept it.
+class _FakeFileSelector extends FileSelectorPlatform {
+ _FakeFileSelector({
+ this.pickResult,
+ this.pickName,
+ this.saveResult,
+ this.saveThrows = false,
+ });
+
+ final Uint8List? pickResult;
+ final String? pickName;
+ final String? saveResult;
+ final bool saveThrows;
+
+ @override
+ Future openFile({
+ List? acceptedTypeGroups,
+ String? initialDirectory,
+ String? confirmButtonText,
+ }) async {
+ if (pickResult == null) return null;
+ return XFile.fromData(pickResult!, name: pickName ?? 'picked.json');
+ }
+
+ @override
+ Future getSaveLocation({
+ List? acceptedTypeGroups,
+ SaveDialogOptions options = const SaveDialogOptions(),
+ }) async {
+ if (saveThrows) {
+ throw PlatformException(code: 'boom', message: 'disk full');
+ }
+ if (saveResult == null) return null;
+ return FileSaveLocation(saveResult!);
+ }
+}
+
+/// Installs a fake file_selector platform for the current test.
+///
+/// `pickResult`: bytes returned by openFile (null → user cancelled).
+/// `saveResult`: path returned by getSaveLocation (null → user cancelled).
+/// `saveThrows`: getSaveLocation raises a PlatformException.
+void mockFilePicker({
+ Uint8List? pickResult,
+ String? pickName,
+ String? saveResult,
+ bool saveThrows = false,
+}) {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ FileSelectorPlatform.instance = _FakeFileSelector(
+ pickResult: pickResult,
+ pickName: pickName,
+ saveResult: saveResult,
+ saveThrows: saveThrows,
+ );
+ // Record the write instead of touching the disk: a real dart:io future never
+ // completes inside testWidgets' fake-async zone, so the export flow would
+ // stall before showing its snackbar.
+ exportedFiles.clear();
+ debugSetExportWriter((path, bytes, mimeType) async {
+ exportedFiles[path] = bytes;
+ });
+}
+
+/// Paths and payloads captured by the stub export writer.
+final Map exportedFiles = {};
+
+/// Points path_provider's documents directory at [dir].
+void mockPathProvider(Directory dir) {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(_pathChannel, (call) async => dir.path);
+}
+
+/// Clears the path_provider channel mock and the file_selector fake.
+void clearChannelMocks() {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+ .setMockMethodCallHandler(_pathChannel, null);
+ FileSelectorPlatform.instance = _FakeFileSelector();
+ debugSetExportWriter(null);
+}
+
+/// Creates an [AppState] persisted under a fresh temp directory.
+AppState tempState() {
+ final dir = Directory.systemTemp.createTempSync('billsplit_ui');
+ addTearDown(() => dir.deleteSync(recursive: true));
+ return AppState(overrideDir: dir)..loaded = true;
+}
+
+/// Pumps [home] inside the app shell with [state] provided.
+Future pumpApp(WidgetTester tester, AppState state, Widget home) {
+ return tester.pumpWidget(
+ ChangeNotifierProvider.value(
+ value: state,
+ child: MaterialApp(home: home),
+ ),
+ );
+}
+
+/// Fires the debounced save timer so tests end with no pending timers.
+Future flushSaves(WidgetTester tester) =>
+ tester.pump(const Duration(milliseconds: 450));
diff --git a/billsplit/test/models_test.dart b/billsplit/test/models_test.dart
new file mode 100644
index 0000000..0cfbb72
--- /dev/null
+++ b/billsplit/test/models_test.dart
@@ -0,0 +1,78 @@
+import 'dart:convert';
+
+import 'package:billsplit/domain/models.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ test('formatPln handles zero, cents padding and negatives', () {
+ expect(formatPln(0), '0,00 zł');
+ expect(formatPln(5), '0,05 zł');
+ expect(formatPln(123456), '1234,56 zł');
+ expect(formatPln(-7), '-0,07 zł');
+ });
+
+ test('Ids are unique and ordered enough', () {
+ final a = Ids.next();
+ final b = Ids.next();
+ expect(a, isNot(b));
+ });
+
+ test('Person/Group/Assignment/Item/Receipt JSON roundtrips', () {
+ final p = Person(id: 'p1', name: 'Ala', drinks: false);
+ expect(Person.fromJson(p.toJson()).drinks, isFalse);
+
+ final g = Group(id: 'g1', name: 'Meat', memberIds: {'p1'});
+ expect(Group.fromJson(g.toJson()).memberIds, {'p1'});
+
+ final a = Assignment(mode: AssignMode.group, groupId: 'g1');
+ final a2 = Assignment.fromJson(a.toJson());
+ expect(a2.mode, AssignMode.group);
+ expect(a2.groupId, 'g1');
+
+ final i = Item(
+ id: 'i1',
+ name: 'Piwo',
+ totalGr: 500,
+ qty: 2,
+ unitPriceGr: 250,
+ discountGr: -100,
+ category: Categories.alcohol,
+ assignment: Assignment(mode: AssignMode.custom, customIds: {'p1'}),
+ );
+ final i2 = Item.fromJson(
+ jsonDecode(jsonEncode(i.toJson())) as Map,
+ );
+ expect(i2.assignment.customIds, {'p1'});
+ expect(i2.discountGr, -100);
+
+ final r = Receipt(
+ id: 'r1',
+ title: 't',
+ store: 's',
+ items: [i],
+ participantIds: {'p1'},
+ paidTotalGr: 500,
+ );
+ final r2 = Receipt.fromJson(
+ jsonDecode(jsonEncode(r.toJson())) as Map,
+ );
+ expect(r2.itemsTotalGr, 500);
+ expect(r2.participantIds, {'p1'});
+ expect(r2.store, 's');
+ });
+
+ test('fromJson tolerates missing optional fields', () {
+ final p = Person.fromJson(const {'id': 'x', 'name': 'n'});
+ expect(p.drinks, isTrue);
+ final g = Group.fromJson(const {'id': 'x', 'name': 'n'});
+ expect(g.memberIds, isEmpty);
+ final a = Assignment.fromJson(const {});
+ expect(a.mode, AssignMode.everyone);
+ final i = Item.fromJson(const {'id': 'x', 'name': 'n', 'totalGr': 1});
+ expect(i.qty, 1);
+ expect(i.category, Categories.general);
+ final r = Receipt.fromJson(const {'id': 'x', 'title': 't', 'date': 'bad'});
+ expect(r.items, isEmpty);
+ expect(r.paidTotalGr, isNull);
+ });
+}
diff --git a/billsplit/test/people_flows_test.dart b/billsplit/test/people_flows_test.dart
new file mode 100644
index 0000000..7c297b1
--- /dev/null
+++ b/billsplit/test/people_flows_test.dart
@@ -0,0 +1,154 @@
+// People screen flows.
+
+import 'package:billsplit/domain/models.dart';
+import 'package:billsplit/state/app_state.dart';
+import 'package:billsplit/ui/people_screen.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'helpers.dart';
+
+Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
+ final r = Receipt(
+ id: 'r1',
+ title: 'Demo',
+ store: 'Biedronka',
+ paidTotalGr: 1000,
+ items: [
+ Item(id: 'i1', name: 'Bread', totalGr: 500),
+ Item(
+ id: 'i2',
+ name: 'Beer',
+ totalGr: 300,
+ category: Categories.alcohol,
+ assignment: Assignment(mode: AssignMode.drinkers),
+ ),
+ Item(
+ id: 'i3',
+ name: 'Tonic',
+ totalGr: 100,
+ category: Categories.mixer,
+ ),
+ Item(
+ id: 'i4',
+ name: 'Kaucja',
+ totalGr: 100,
+ category: Categories.deposit,
+ ),
+ if (withUnassigned)
+ Item(
+ id: 'i5',
+ name: 'Ghost',
+ totalGr: 50,
+ assignment: Assignment(mode: AssignMode.custom),
+ ),
+ ],
+ );
+ s.addReceipt(r);
+ return r;
+}
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ group('people', () {
+ testWidgets('add person dialog: toggle, cancel, empty, valid',
+ (tester) async {
+ final s = tempState();
+ await pumpApp(tester, s, const PeopleScreen());
+ expect(find.textContaining('Nobody yet'), findsOneWidget);
+
+ await tester.tap(find.text('Add person'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byType(Switch));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Cancel'));
+ await tester.pumpAndSettle();
+ expect(s.people, isEmpty);
+
+ await tester.tap(find.text('Add person'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Add'));
+ await tester.pumpAndSettle();
+ expect(s.people, isEmpty);
+
+ await tester.tap(find.text('Add person'));
+ await tester.pumpAndSettle();
+ await tester.enterText(find.byType(TextField), 'Kasia');
+ await tester.tap(find.byType(Switch));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Add'));
+ await tester.pumpAndSettle();
+ expect(s.people.single.name, 'Kasia');
+ expect(s.people.single.drinks, isFalse);
+ await flushSaves(tester);
+ });
+
+ testWidgets('drinks switch, membership early-return, delete flows',
+ (tester) async {
+ final s = tempState();
+ final p = s.addPerson('Ala');
+ await pumpApp(tester, s, const PeopleScreen());
+ await flushSaves(tester);
+
+ await tester.tap(find.byType(Switch).first);
+ await tester.pumpAndSettle();
+ expect(p.drinks, isFalse);
+
+ // No groups yet → tapping the person is a no-op branch.
+ await tester.tap(find.text('Ala'));
+ await tester.pumpAndSettle();
+ expect(find.text('Group membership'), findsNothing);
+
+ await tester.tap(find.byIcon(Icons.delete_outline).first);
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Cancel'));
+ await tester.pumpAndSettle();
+ expect(s.people, hasLength(1));
+
+ await tester.tap(find.byIcon(Icons.delete_outline).first);
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Remove'));
+ await tester.pumpAndSettle();
+ expect(s.people, isEmpty);
+ await flushSaves(tester);
+ });
+
+ testWidgets('groups: add, membership sheet, delete', (tester) async {
+ final s = tempState()..addPerson('Ala');
+ await pumpApp(tester, s, const PeopleScreen());
+ await flushSaves(tester);
+
+ await tester.tap(find.text('Add group'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Cancel'));
+ await tester.pumpAndSettle();
+ expect(s.groups, isEmpty);
+
+ await tester.tap(find.text('Add group'));
+ await tester.pumpAndSettle();
+ await tester.enterText(find.byType(TextField), 'Meat');
+ await tester.tap(find.text('Add'));
+ await tester.pumpAndSettle();
+ expect(s.groups.single.name, 'Meat');
+ expect(find.textContaining('No members'), findsOneWidget);
+
+ await tester.tap(find.text('Ala'));
+ await tester.pumpAndSettle();
+ expect(find.byType(CheckboxListTile), findsOneWidget);
+ await tester.tap(find.byType(CheckboxListTile));
+ await tester.pumpAndSettle();
+ expect(s.groups.single.memberIds, hasLength(1));
+ await tester.tap(find.byType(CheckboxListTile));
+ await tester.pumpAndSettle();
+ expect(s.groups.single.memberIds, isEmpty);
+ await tester.tapAt(const Offset(10, 10)); // close sheet
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byIcon(Icons.delete_outline).last);
+ await tester.pumpAndSettle();
+ expect(s.groups, isEmpty);
+ await flushSaves(tester);
+ });
+ });
+}
diff --git a/billsplit/test/receipt_flows_test.dart b/billsplit/test/receipt_flows_test.dart
new file mode 100644
index 0000000..09f5880
--- /dev/null
+++ b/billsplit/test/receipt_flows_test.dart
@@ -0,0 +1,140 @@
+// Receipt screen flows.
+
+import 'package:billsplit/domain/models.dart';
+import 'package:billsplit/state/app_state.dart';
+import 'package:billsplit/ui/receipt_screen.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'helpers.dart';
+
+Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
+ final r = Receipt(
+ id: 'r1',
+ title: 'Demo',
+ store: 'Biedronka',
+ paidTotalGr: 1000,
+ items: [
+ Item(id: 'i1', name: 'Bread', totalGr: 500),
+ Item(
+ id: 'i2',
+ name: 'Beer',
+ totalGr: 300,
+ category: Categories.alcohol,
+ assignment: Assignment(mode: AssignMode.drinkers),
+ ),
+ Item(
+ id: 'i3',
+ name: 'Tonic',
+ totalGr: 100,
+ category: Categories.mixer,
+ ),
+ Item(
+ id: 'i4',
+ name: 'Kaucja',
+ totalGr: 100,
+ category: Categories.deposit,
+ ),
+ if (withUnassigned)
+ Item(
+ id: 'i5',
+ name: 'Ghost',
+ totalGr: 50,
+ assignment: Assignment(mode: AssignMode.custom),
+ ),
+ ],
+ );
+ s.addReceipt(r);
+ return r;
+}
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ group('receipt screen', () {
+ testWidgets('unknown id shows deleted note', (tester) async {
+ final s = tempState();
+ await pumpApp(tester, s, const ReceiptScreen(receiptId: 'nope'));
+ expect(find.text('Receipt deleted.'), findsOneWidget);
+ });
+
+ testWidgets('tabs on narrow, panes on wide', (tester) async {
+ final s = tempState()..addPerson('Ala');
+ demoReceipt(s);
+ await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
+ await flushSaves(tester);
+ expect(find.byType(TabBar), findsOneWidget);
+ await tester.tap(find.text('Totals'));
+ await tester.pumpAndSettle();
+ expect(find.textContaining('grosz-exact'), findsOneWidget);
+
+ tester.view.physicalSize = const Size(1600, 900);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.reset);
+ await tester.pumpAndSettle();
+ expect(find.byType(TabBar), findsNothing);
+ expect(find.byType(VerticalDivider), findsOneWidget);
+ });
+
+ testWidgets('participants: empty roster snackbar, then editing',
+ (tester) async {
+ final s = tempState();
+ demoReceipt(s);
+ await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
+ await flushSaves(tester);
+ await tester.tap(find.byIcon(Icons.group));
+ await tester.pumpAndSettle();
+ expect(find.textContaining('Add people first'), findsOneWidget);
+
+ final p = s.addPerson('Ala');
+ s.addPerson('Bob');
+ await tester.pumpAndSettle();
+ await tester.tap(find.byIcon(Icons.group));
+ await tester.pumpAndSettle();
+ expect(find.text('Who is on this receipt?'), findsOneWidget);
+ await tester.tap(find.byType(CheckboxListTile).first);
+ await tester.pumpAndSettle();
+ expect(s.receipts.single.participantIds.contains(p.id), isTrue);
+ await tester.tap(find.byType(CheckboxListTile).first);
+ await tester.pumpAndSettle();
+ await tester.tapAt(const Offset(10, 10));
+ await tester.pumpAndSettle();
+ await flushSaves(tester);
+ });
+
+ testWidgets('export: no participants, success, cancel, failure',
+ (tester) async {
+ final s = tempState();
+ demoReceipt(s);
+ await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
+ await flushSaves(tester);
+
+ await tester.tap(find.byIcon(Icons.table_view));
+ await tester.pumpAndSettle();
+ expect(find.textContaining('Add participants'), findsOneWidget);
+
+ s.addPerson('Ala');
+ s.receipts.single.participantIds.addAll(s.people.map((p) => p.id));
+ mockFilePicker(saveResult: '/tmp/out.xlsx');
+ await tester.pump(const Duration(seconds: 5)); // expire prior snackbar
+ await tester.pumpAndSettle();
+ await tester.tap(find.byIcon(Icons.table_view));
+ await tester.pumpAndSettle();
+ expect(find.textContaining('Saved: out.xlsx'), findsOneWidget);
+
+ mockFilePicker();
+ await tester.pump(const Duration(seconds: 5));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byIcon(Icons.table_view));
+ await tester.pumpAndSettle();
+
+ mockFilePicker(saveThrows: true);
+ await tester.pump(const Duration(seconds: 5));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byIcon(Icons.table_view));
+ await tester.pumpAndSettle();
+ expect(find.textContaining('Export failed'), findsOneWidget);
+ await flushSaves(tester);
+ });
+ });
+}
diff --git a/billsplit/test/receipt_items_test.dart b/billsplit/test/receipt_items_test.dart
new file mode 100644
index 0000000..07059e9
--- /dev/null
+++ b/billsplit/test/receipt_items_test.dart
@@ -0,0 +1,195 @@
+// Receipt item list and assignment sheet flows.
+
+// Receipt screen flows.
+
+import 'package:billsplit/domain/models.dart';
+import 'package:billsplit/state/app_state.dart';
+import 'package:billsplit/ui/receipt_screen.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'helpers.dart';
+
+Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
+ final r = Receipt(
+ id: 'r1',
+ title: 'Demo',
+ store: 'Biedronka',
+ paidTotalGr: 1000,
+ items: [
+ Item(id: 'i1', name: 'Bread', totalGr: 500),
+ Item(
+ id: 'i2',
+ name: 'Beer',
+ totalGr: 300,
+ category: Categories.alcohol,
+ assignment: Assignment(mode: AssignMode.drinkers),
+ ),
+ Item(
+ id: 'i3',
+ name: 'Tonic',
+ totalGr: 100,
+ category: Categories.mixer,
+ ),
+ Item(
+ id: 'i4',
+ name: 'Kaucja',
+ totalGr: 100,
+ category: Categories.deposit,
+ ),
+ if (withUnassigned)
+ Item(
+ id: 'i5',
+ name: 'Ghost',
+ totalGr: 50,
+ assignment: Assignment(mode: AssignMode.custom),
+ ),
+ ],
+ );
+ s.addReceipt(r);
+ return r;
+}
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ group('receipt items', () {
+ testWidgets('items list: labels, unassigned, edit dialog paths',
+ (tester) async {
+ final s = tempState();
+ final p = s.addPerson('Ala');
+ s.addPerson('Bob', drinks: false);
+ final g = s.addGroup('Meat')..memberIds.add(p.id);
+ final r = demoReceipt(s, withUnassigned: true);
+ r.items[0].assignment = Assignment(mode: AssignMode.group, groupId: g.id);
+ r.items[2].assignment = Assignment(mode: AssignMode.nonDrinkers);
+ r.items[3].assignment = Assignment(
+ mode: AssignMode.group,
+ groupId: 'gone',
+ );
+ await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
+ await flushSaves(tester);
+
+ expect(find.textContaining('Group: Meat'), findsOneWidget);
+ expect(find.textContaining('Group: ?'), findsOneWidget);
+ expect(find.textContaining('Drinkers'), findsOneWidget);
+ expect(find.textContaining('Non-drinkers'), findsOneWidget);
+ expect(find.textContaining('UNASSIGNED'), findsOneWidget);
+
+ await tester.tap(find.text('Totals'));
+ await tester.pumpAndSettle();
+ expect(find.textContaining('excluded from totals'), findsOneWidget);
+ await tester.tap(find.text('Items'));
+ await tester.pumpAndSettle();
+
+ // Edit via long-press: invalid total keeps dialog open, then fix+save.
+ await tester.longPress(find.text('Bread'));
+ await tester.pumpAndSettle();
+ await tester.enterText(
+ find.widgetWithText(TextField, 'Total (zł)'),
+ 'abc',
+ );
+ await tester.tap(find.text('Save'));
+ await tester.pumpAndSettle();
+ expect(find.text('Save'), findsOneWidget); // still open
+ await tester.enterText(
+ find.widgetWithText(TextField, 'Total (zł)'),
+ '6,00',
+ );
+ await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Rolls');
+ await tester.tap(find.text('Save'));
+ await tester.pumpAndSettle();
+ expect(r.items[0].totalGr, 600);
+ expect(r.items[0].name, 'Rolls');
+
+ // Delete an item from the edit dialog.
+ await tester.longPress(find.text('Ghost'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Delete'));
+ await tester.pumpAndSettle();
+ expect(r.items.any((i) => i.name == 'Ghost'), isFalse);
+
+ // Add a new alcohol item via the FAB: cancel first, then create.
+ await tester.tap(find.byType(FloatingActionButton));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Cancel'));
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byType(FloatingActionButton));
+ await tester.pumpAndSettle();
+ await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Wine');
+ await tester.enterText(
+ find.widgetWithText(TextField, 'Total (zł)'),
+ '30',
+ );
+ await tester.tap(find.byType(DropdownButtonFormField));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Alcohol').last);
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Save'));
+ await tester.pumpAndSettle();
+ final wine = r.items.singleWhere((i) => i.name == 'Wine');
+ expect(wine.totalGr, 3000);
+ expect(wine.assignment.mode, AssignMode.drinkers);
+
+ await tester.tap(find.byType(FloatingActionButton));
+ await tester.pumpAndSettle();
+ await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Chips');
+ await tester.enterText(
+ find.widgetWithText(TextField, 'Total (zł)'),
+ '5',
+ );
+ await tester.tap(find.text('Save'));
+ await tester.pumpAndSettle();
+ expect(
+ r.items.singleWhere((i) => i.name == 'Chips').assignment.mode,
+ AssignMode.everyone,
+ );
+ await flushSaves(tester);
+ });
+
+ testWidgets('assignment sheet: modes, materialization, nobody',
+ (tester) async {
+ final s = tempState();
+ final ala = s.addPerson('Ala');
+ s.addPerson('Bob');
+ final g = s.addGroup('Meat')..memberIds.add(ala.id);
+ final r = demoReceipt(s);
+ await pumpApp(tester, s, const ReceiptScreen(receiptId: 'r1'));
+ await flushSaves(tester);
+
+ await tester.tap(find.text('Bread'));
+ await tester.pumpAndSettle();
+ final item = r.items[0];
+
+ await tester.tap(find.widgetWithText(ChoiceChip, 'Drinkers'));
+ await tester.pumpAndSettle();
+ expect(item.assignment.mode, AssignMode.drinkers);
+ await tester.tap(find.widgetWithText(ChoiceChip, 'Non-drinkers'));
+ await tester.pumpAndSettle();
+ expect(item.assignment.mode, AssignMode.nonDrinkers);
+ expect(find.textContaining('Nobody selected'), findsOneWidget);
+ await tester.tap(find.widgetWithText(ChoiceChip, 'Meat'));
+ await tester.pumpAndSettle();
+ expect(item.assignment.mode, AssignMode.group);
+ expect(item.assignment.groupId, g.id);
+ await tester.tap(find.widgetWithText(ChoiceChip, 'Everyone'));
+ await tester.pumpAndSettle();
+ expect(item.assignment.mode, AssignMode.everyone);
+
+ // Toggling a person on a symbolic mode materializes to custom.
+ await tester.tap(find.byType(CheckboxListTile).first);
+ await tester.pumpAndSettle();
+ expect(item.assignment.mode, AssignMode.custom);
+ expect(item.assignment.customIds, hasLength(1));
+ await tester.tap(find.byType(CheckboxListTile).last);
+ await tester.pumpAndSettle();
+ expect(item.assignment.customIds, isEmpty);
+ expect(find.textContaining('Nobody selected'), findsOneWidget);
+ await tester.tap(find.byType(CheckboxListTile).first);
+ await tester.pumpAndSettle();
+ expect(item.assignment.customIds, hasLength(1));
+ await flushSaves(tester);
+ });
+ });
+}
diff --git a/billsplit/test/split_engine_test.dart b/billsplit/test/split_engine_test.dart
new file mode 100644
index 0000000..9451f67
--- /dev/null
+++ b/billsplit/test/split_engine_test.dart
@@ -0,0 +1,189 @@
+import 'dart:io';
+import 'dart:math';
+
+import 'package:billsplit/domain/eparagon_parser.dart';
+import 'package:billsplit/domain/models.dart';
+import 'package:billsplit/domain/split_engine.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+List makePeople(int n, {Set nonDrinkers = const {}}) => [
+ for (var i = 0; i < n; i++)
+ Person(id: 'p$i', name: 'P$i', drinks: !nonDrinkers.contains(i)),
+ ];
+
+void main() {
+ test('splitAmount: exact sum, deterministic, rotation moves the extra', () {
+ final shares = splitAmount(100, ['b', 'a', 'c']); // rotation 0
+ expect(shares.values.reduce((a, b) => a + b), 100);
+ expect(shares['a'], 34); // sorted order, start at index 0
+ expect(shares['b'], 33);
+ expect(shares['c'], 33);
+ final rotated = splitAmount(100, ['b', 'a', 'c'], rotation: 1);
+ expect(rotated.values.reduce((a, b) => a + b), 100);
+ expect(rotated['b'], 34); // start shifted by one
+ // Same inputs -> same output (deterministic).
+ expect(splitAmount(100, ['b', 'a', 'c'], rotation: 1), rotated);
+ });
+
+ test('splitAmount: negative totals (returns) also sum exactly', () {
+ final shares = splitAmount(-100, ['a', 'b', 'c']);
+ expect(shares.values.reduce((a, b) => a + b), -100);
+ expect(shares.values.every((v) => v == -33 || v == -34), isTrue);
+ });
+
+ test('splitAmount fuzz: sum invariant over random cases', () {
+ final rnd = Random(42);
+ for (var i = 0; i < 500; i++) {
+ final n = 1 + rnd.nextInt(9);
+ final total = rnd.nextInt(100000) - 5000;
+ final ids = List.generate(n, (j) => 'p$j');
+ final shares = splitAmount(total, ids, rotation: rnd.nextInt(1000));
+ expect(
+ shares.values.fold(0, (a, b) => a + b),
+ total,
+ reason: 'total=$total n=$n',
+ );
+ final values = shares.values.toList();
+ expect(
+ values.reduce(max) - values.reduce(min),
+ lessThanOrEqualTo(1),
+ reason: 'shares differ by more than 1 grosz',
+ );
+ }
+ });
+
+ test('modes resolve correctly and intersect with receipt participants', () {
+ final people = makePeople(4, nonDrinkers: {3});
+ final group = Group(id: 'g1', name: 'Meat', memberIds: {'p0', 'p2'});
+ final receipt = Receipt(
+ id: 'r',
+ title: 't',
+ participantIds: {'p0', 'p1', 'p3'}, // p2 not on this receipt
+ );
+ Item mk(Assignment a) =>
+ Item(id: Ids.next(), name: 'x', totalGr: 300, assignment: a);
+
+ expect(
+ resolveParticipants(mk(Assignment()), receipt, people, [group])
+ .map((p) => p.id),
+ ['p0', 'p1', 'p3'],
+ );
+ expect(
+ resolveParticipants(
+ mk(Assignment(mode: AssignMode.drinkers)),
+ receipt,
+ people,
+ [group],
+ ).map((p) => p.id),
+ ['p0', 'p1'],
+ );
+ expect(
+ resolveParticipants(
+ mk(Assignment(mode: AssignMode.nonDrinkers)),
+ receipt,
+ people,
+ [group],
+ ).map((p) => p.id),
+ ['p3'],
+ );
+ // Group intersected with receipt pool: p2 excluded.
+ expect(
+ resolveParticipants(
+ mk(Assignment(mode: AssignMode.group, groupId: 'g1')),
+ receipt,
+ people,
+ [group],
+ ).map((p) => p.id),
+ ['p0'],
+ );
+ expect(
+ resolveParticipants(
+ mk(Assignment(mode: AssignMode.custom, customIds: {'p1', 'p2'})),
+ receipt,
+ people,
+ [group],
+ ).map((p) => p.id),
+ ['p1'],
+ );
+ // Missing group -> unassigned.
+ expect(
+ resolveParticipants(
+ mk(Assignment(mode: AssignMode.group, groupId: 'gone')),
+ receipt,
+ people,
+ [group],
+ ),
+ isEmpty,
+ );
+ });
+
+ test('real receipt: 8 people, all drinkers — reconciles to 597.55', () {
+ final receipt = parseEParagon(
+ File('test/fixtures/sample_receipt.json').readAsStringSync(),
+ );
+ final people = makePeople(8);
+ receipt.participantIds.addAll(people.map((p) => p.id));
+
+ final split = computeSplit(receipt, people, []);
+ expect(split.unassigned, isEmpty);
+ // Exact reconciliation is the hard invariant.
+ expect(split.assignedTotalGr, 59755);
+ // 59755 / 8 = 7469.375 -> everyone lands within a few grosze of that;
+ // remainder rotation spreads the extras instead of stacking them on the
+ // alphabetically-first person.
+ final totals = split.perPerson.values.map((t) => t.totalGr).toList();
+ for (final t in totals) {
+ expect(t, inInclusiveRange(7469 - 15, 7470 + 15));
+ }
+ // Alcohol: 79.98 + 28.74 = 108.72 split over 8 -> 13.59 each ±2 gr.
+ final alcohol = split.perPerson.values.map((t) => t.alcoholGr).toList();
+ expect(alcohol.fold(0, (a, b) => a + b), 10872);
+ for (final a in alcohol) {
+ expect(a, inInclusiveRange(1357, 1361));
+ }
+ });
+
+ test('real receipt: 2 non-drinkers pay no alcohol, totals still exact', () {
+ final receipt = parseEParagon(
+ File('test/fixtures/sample_receipt.json').readAsStringSync(),
+ );
+ final people = makePeople(8, nonDrinkers: {6, 7});
+ receipt.participantIds.addAll(people.map((p) => p.id));
+
+ final split = computeSplit(receipt, people, []);
+ expect(split.assignedTotalGr, 59755);
+ expect(split.perPerson['p6']!.alcoholGr, 0);
+ expect(split.perPerson['p7']!.alcoholGr, 0);
+ // 10872 over 6 drinkers = 1812 each ±2 gr, summing exactly.
+ final drinkerAlcohol = [
+ for (var i = 0; i < 6; i++) split.perPerson['p$i']!.alcoholGr,
+ ];
+ expect(drinkerAlcohol.fold(0, (a, b) => a + b), 10872);
+ for (final a in drinkerAlcohol) {
+ expect(a, inInclusiveRange(1810, 1814));
+ }
+ // Non-drinkers pay strictly less.
+ expect(
+ split.perPerson['p7']!.totalGr,
+ lessThan(split.perPerson['p0']!.totalGr),
+ );
+ });
+
+ test('items with no possible participants are reported, not lost', () {
+ final people = makePeople(2); // both drinkers
+ final receipt = Receipt(id: 'r', title: 't', participantIds: {'p0', 'p1'});
+ receipt.items.add(
+ Item(
+ id: 'i1',
+ name: 'water',
+ totalGr: 500,
+ assignment: Assignment(mode: AssignMode.nonDrinkers),
+ ),
+ );
+ receipt.items.add(Item(id: 'i2', name: 'bread', totalGr: 300));
+
+ final split = computeSplit(receipt, people, []);
+ expect(split.unassigned.map((i) => i.id), ['i1']);
+ expect(split.assignedTotalGr, 300);
+ });
+}
diff --git a/billsplit/test/ui_flows_test.dart b/billsplit/test/ui_flows_test.dart
new file mode 100644
index 0000000..4245290
--- /dev/null
+++ b/billsplit/test/ui_flows_test.dart
@@ -0,0 +1,210 @@
+import 'dart:convert';
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:billsplit/domain/models.dart';
+import 'package:billsplit/main.dart' as app;
+import 'package:billsplit/state/app_state.dart';
+import 'package:billsplit/ui/home_screen.dart';
+import 'package:billsplit/ui/people_screen.dart';
+import 'package:billsplit/ui/receipt_screen.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'helpers.dart';
+
+Receipt demoReceipt(AppState s, {bool withUnassigned = false}) {
+ final r = Receipt(
+ id: 'r1',
+ title: 'Demo',
+ store: 'Biedronka',
+ paidTotalGr: 1000,
+ items: [
+ Item(id: 'i1', name: 'Bread', totalGr: 500),
+ Item(
+ id: 'i2',
+ name: 'Beer',
+ totalGr: 300,
+ category: Categories.alcohol,
+ assignment: Assignment(mode: AssignMode.drinkers),
+ ),
+ Item(
+ id: 'i3',
+ name: 'Tonic',
+ totalGr: 100,
+ category: Categories.mixer,
+ ),
+ Item(
+ id: 'i4',
+ name: 'Kaucja',
+ totalGr: 100,
+ category: Categories.deposit,
+ ),
+ if (withUnassigned)
+ Item(
+ id: 'i5',
+ name: 'Ghost',
+ totalGr: 50,
+ assignment: Assignment(mode: AssignMode.custom),
+ ),
+ ],
+ );
+ s.addReceipt(r);
+ return r;
+}
+
+void main() {
+ tearDown(clearChannelMocks);
+
+ group('main()', () {
+ testWidgets('boots with the real load path', (tester) async {
+ final dir = Directory.systemTemp.createTempSync('billsplit_main');
+ addTearDown(() => dir.deleteSync(recursive: true));
+ mockPathProvider(dir);
+ app.main();
+ await tester.pump();
+ await tester.pump();
+ expect(find.text('BillSplit'), findsOneWidget);
+ });
+ });
+
+ group('home', () {
+ testWidgets('import cancel does nothing', (tester) async {
+ mockFilePicker();
+ final s = tempState();
+ await pumpApp(tester, s, const HomeScreen());
+ await tester.tap(find.text('Import e-paragon'));
+ await tester.pumpAndSettle();
+ expect(s.receipts, isEmpty);
+ });
+
+ testWidgets('import parse error shows message', (tester) async {
+ mockFilePicker(pickResult: Uint8List.fromList('zzz'.codeUnits));
+ final s = tempState();
+ await pumpApp(tester, s, const HomeScreen());
+ await tester.tap(find.text('Import e-paragon'));
+ await tester.pumpAndSettle();
+ expect(find.textContaining('neither valid JSON'), findsOneWidget);
+ await flushSaves(tester);
+ });
+
+ testWidgets('import invalid UTF-8 hits the generic handler',
+ (tester) async {
+ mockFilePicker(pickResult: Uint8List.fromList([0xFF, 0xFE, 0x00]));
+ final s = tempState();
+ await pumpApp(tester, s, const HomeScreen());
+ await tester.tap(find.text('Import e-paragon'));
+ await tester.pumpAndSettle();
+ expect(find.textContaining('Import failed'), findsOneWidget);
+ });
+
+ testWidgets('import success navigates; warning shows a snackbar',
+ (tester) async {
+ final fixture =
+ File('test/fixtures/sample_receipt.json').readAsBytesSync();
+ mockFilePicker(pickResult: fixture);
+ final s = tempState()..addPerson('Ala');
+ await pumpApp(tester, s, const HomeScreen());
+ await tester.tap(find.text('Import e-paragon'));
+ await tester.pumpAndSettle();
+ expect(s.receipts, hasLength(1));
+ expect(find.byType(ReceiptScreen), findsOneWidget);
+ await flushSaves(tester);
+ });
+
+ testWidgets('import surfaces parser warnings', (tester) async {
+ final payload = {
+ 'dokument': {
+ 'paragon': {
+ 'pozycja': [
+ {
+ 'towar': {'nazwa': 'Chleb', 'ilosc': 1, 'brutto': 500},
+ },
+ ],
+ 'podsum': {'sumaBrutto': 999},
+ },
+ },
+ };
+ final body = base64Url.encode(utf8.encode(jsonEncode(payload)));
+ final wrapper = jsonEncode({'data': 'h.$body.s'});
+ mockFilePicker(pickResult: Uint8List.fromList(utf8.encode(wrapper)));
+ final s = tempState();
+ await pumpApp(tester, s, const HomeScreen());
+ await tester.tap(find.text('Import e-paragon'));
+ await tester.pumpAndSettle();
+ expect(find.textContaining('differs'), findsOneWidget);
+ await flushSaves(tester);
+ });
+
+ testWidgets('manual create: cancel, empty and valid', (tester) async {
+ final s = tempState();
+ await pumpApp(tester, s, const HomeScreen());
+
+ await tester.tap(find.text('New receipt'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Cancel'));
+ await tester.pumpAndSettle();
+ expect(s.receipts, isEmpty);
+
+ await tester.tap(find.text('New receipt'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Create'));
+ await tester.pumpAndSettle();
+ expect(s.receipts, isEmpty);
+
+ await tester.tap(find.text('New receipt'));
+ await tester.pumpAndSettle();
+ await tester.enterText(find.byType(TextField), 'Pizza night');
+ await tester.tap(find.text('Create'));
+ await tester.pumpAndSettle();
+ expect(s.receipts.single.title, 'Pizza night');
+ expect(find.byType(ReceiptScreen), findsOneWidget);
+ await tester.pageBack();
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('New receipt'));
+ await tester.pumpAndSettle();
+ await tester.enterText(find.byType(TextField), 'Kebab');
+ await tester.testTextInput.receiveAction(TextInputAction.done);
+ await tester.pumpAndSettle();
+ expect(s.receipts.first.title, 'Kebab');
+ await flushSaves(tester);
+ });
+
+ testWidgets('list tile navigates; dismiss asks and deletes',
+ (tester) async {
+ final s = tempState();
+ demoReceipt(s);
+ await pumpApp(tester, s, const HomeScreen());
+ await flushSaves(tester);
+
+ expect(find.textContaining('4 items'), findsOneWidget);
+ await tester.tap(find.text('Demo'));
+ await tester.pumpAndSettle();
+ expect(find.byType(ReceiptScreen), findsOneWidget);
+ await tester.pageBack();
+ await tester.pumpAndSettle();
+
+ await tester.drag(find.byType(Dismissible), const Offset(-600, 0));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Cancel'));
+ await tester.pumpAndSettle();
+ expect(s.receipts, hasLength(1));
+
+ await tester.drag(find.byType(Dismissible), const Offset(-600, 0));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Delete'));
+ await tester.pumpAndSettle();
+ expect(s.receipts, isEmpty);
+ await flushSaves(tester);
+ });
+
+ testWidgets('people button opens roster', (tester) async {
+ final s = tempState();
+ await pumpApp(tester, s, const HomeScreen());
+ await tester.tap(find.byIcon(Icons.group));
+ await tester.pumpAndSettle();
+ expect(find.byType(PeopleScreen), findsOneWidget);
+ });
+ });
+}
diff --git a/billsplit/test/widget_test.dart b/billsplit/test/widget_test.dart
new file mode 100644
index 0000000..e37c0ab
--- /dev/null
+++ b/billsplit/test/widget_test.dart
@@ -0,0 +1,16 @@
+import 'package:billsplit/main.dart';
+import 'package:billsplit/state/app_state.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('app builds and shows empty state', (tester) async {
+ // Real file IO never completes inside the fake-async test zone, so skip
+ // load() and mark the state ready directly; this is a pure widget smoke
+ // test (persistence is exercised by the domain tests / at runtime).
+ final state = AppState()..loaded = true;
+ await tester.pumpWidget(BillSplitApp(state: state));
+ await tester.pump();
+ expect(find.text('BillSplit'), findsOneWidget);
+ expect(find.textContaining('No receipts yet'), findsOneWidget);
+ });
+}
diff --git a/billsplit/test/xlsx_export_test.dart b/billsplit/test/xlsx_export_test.dart
new file mode 100644
index 0000000..f9be9de
--- /dev/null
+++ b/billsplit/test/xlsx_export_test.dart
@@ -0,0 +1,31 @@
+import 'dart:io';
+
+import 'package:billsplit/domain/eparagon_parser.dart';
+import 'package:billsplit/domain/models.dart';
+import 'package:billsplit/domain/xlsx_export.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ test('exports a non-trivial xlsx for the real receipt', () {
+ final receipt = parseEParagon(
+ File('test/fixtures/sample_receipt.json').readAsStringSync(),
+ );
+ final people = [
+ for (var i = 0; i < 8; i++)
+ Person(id: 'p$i', name: 'Person ${i + 1}', drinks: i < 6),
+ ];
+ receipt.participantIds.addAll(people.map((p) => p.id));
+
+ final bytes = exportXlsx(receipt: receipt, roster: people, groups: []);
+ expect(bytes.length, greaterThan(4000));
+ // PK zip magic.
+ expect(bytes[0], 0x50);
+ expect(bytes[1], 0x4B);
+
+ // Write out for external validation (recalc via LibreOffice + openpyxl).
+ final out = File('build/test_export.xlsx')
+ ..createSync(recursive: true)
+ ..writeAsBytesSync(bytes);
+ expect(out.existsSync(), isTrue);
+ });
+}
diff --git a/billsplit/test/xlsx_extra_test.dart b/billsplit/test/xlsx_extra_test.dart
new file mode 100644
index 0000000..fcf912d
--- /dev/null
+++ b/billsplit/test/xlsx_extra_test.dart
@@ -0,0 +1,56 @@
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:billsplit/domain/models.dart';
+import 'package:billsplit/domain/xlsx_export.dart';
+import 'package:billsplit/ui/receipt_screen.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ test('export handles no store/paid, unassigned items and non-drinkers', () {
+ final people = [
+ Person(id: 'a', name: 'Ala'),
+ Person(id: 'b', name: 'Bob', drinks: false),
+ ];
+ final receipt = Receipt(
+ id: 'r',
+ title: 'Manual',
+ participantIds: {'a', 'b'},
+ items: [
+ Item(id: 'i1', name: 'Bread', totalGr: 500),
+ Item(
+ id: 'i2',
+ name: 'Beer',
+ totalGr: 300,
+ category: Categories.alcohol,
+ assignment: Assignment(mode: AssignMode.drinkers),
+ ),
+ Item(
+ id: 'i3',
+ name: 'Ghost',
+ totalGr: 100,
+ assignment: Assignment(mode: AssignMode.custom),
+ ),
+ ],
+ );
+ final bytes = exportXlsx(receipt: receipt, roster: people, groups: []);
+ expect(bytes.length, greaterThan(1000));
+ expect(bytes[0], 0x50);
+ });
+
+ // Exercises the REAL disk writer, deliberately as a plain `test` rather than
+ // a `testWidgets`: a real dart:io future never completes inside the
+ // fake-async zone, which is the whole reason the export flow writes through
+ // an injectable seam.
+ test('the real export writer puts the bytes on disk', () async {
+ final dir = Directory.systemTemp.createTempSync('billsplit_export');
+ addTearDown(() => dir.deleteSync(recursive: true));
+ final target = '${dir.path}/split.xlsx';
+ final payload = Uint8List.fromList([0x50, 0x4B, 0x03, 0x04, 0x99]);
+
+ debugSetExportWriter(null); // restore the real writer
+ await writeExport(target, payload, 'application/vnd.ms-excel');
+
+ expect(File(target).readAsBytesSync(), payload);
+ });
+}
diff --git a/python_pkg/billsplit_coverage/__init__.py b/python_pkg/billsplit_coverage/__init__.py
new file mode 100644
index 0000000..3f7e006
--- /dev/null
+++ b/python_pkg/billsplit_coverage/__init__.py
@@ -0,0 +1,13 @@
+"""Coverage gate for the ``billsplit/`` Flutter app.
+
+``flutter test --coverage`` reports success while leaving whole files untouched,
+so this package re-reads the lcov report it produces and fails unless every
+``lib/`` file is present *and* fully covered. It lives here rather than beside
+the app because every Python file in this repository belongs under
+``python_pkg/``; the app itself holds only Dart.
+
+Usage:
+ PYTHONPATH=~/testsAndMisc python3 -m python_pkg.billsplit_coverage
+"""
+
+from __future__ import annotations
diff --git a/python_pkg/billsplit_coverage/__main__.py b/python_pkg/billsplit_coverage/__main__.py
new file mode 100644
index 0000000..9e95907
--- /dev/null
+++ b/python_pkg/billsplit_coverage/__main__.py
@@ -0,0 +1,14 @@
+"""Module entry point for the billsplit coverage gate.
+
+Usage:
+ PYTHONPATH=~/testsAndMisc python3 -m python_pkg.billsplit_coverage
+"""
+
+from __future__ import annotations
+
+import sys
+
+from python_pkg.billsplit_coverage.checker import main
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/python_pkg/billsplit_coverage/checker.py b/python_pkg/billsplit_coverage/checker.py
new file mode 100644
index 0000000..d076671
--- /dev/null
+++ b/python_pkg/billsplit_coverage/checker.py
@@ -0,0 +1,130 @@
+"""Fail unless lcov reports 100% line coverage for every ``billsplit/lib`` file.
+
+Two separate checks, because they catch different mistakes: a file present in
+the report but with unhit lines, and a file missing from the report entirely.
+The second is the one that catches a newly added Dart file nothing imports —
+partial coverage is visible in the report, absence is not.
+
+The default project root is resolved from this file's location rather than the
+working directory, so the CI job can invoke the module from anywhere in the
+repository.
+"""
+
+from __future__ import annotations
+
+import argparse
+import pathlib
+import sys
+from typing import Final
+
+# python_pkg/billsplit_coverage/checker.py -> repo root -> billsplit/
+_DEFAULT_PROJECT: Final[pathlib.Path] = (
+ pathlib.Path(__file__).resolve().parents[2] / "billsplit"
+)
+
+EXIT_INCOMPLETE: Final[int] = 1
+EXIT_NO_REPORT: Final[int] = 2
+
+
+def parse_lcov(lcov: pathlib.Path) -> dict[str, list[int]]:
+ """Map each file in an lcov report to its uncovered line numbers.
+
+ Parameters:
+ lcov (pathlib.Path): Path to an ``lcov.info`` report.
+
+ Returns:
+ dict[str, list[int]]: Source path to the lines with a zero hit count. A
+ fully covered file maps to an empty list, which is what lets the caller
+ tell "covered completely" apart from "absent from the report".
+ """
+ uncovered: dict[str, list[int]] = {}
+ current: str | None = None
+ for line in lcov.read_text(encoding="utf-8").splitlines():
+ if line.startswith("SF:"):
+ # lcov quotes the path verbatim; normalise Windows separators.
+ current = line[3:].replace("\\", "/")
+ uncovered.setdefault(current, [])
+ elif line.startswith("DA:") and current is not None:
+ number, hits = line[3:].split(",")
+ if int(hits) == 0:
+ uncovered[current].append(int(number))
+ return uncovered
+
+
+def find_failures(
+ uncovered: dict[str, list[int]],
+ project: pathlib.Path,
+) -> list[str]:
+ """Describe every coverage gap, as lines ready to print.
+
+ Parameters:
+ uncovered (dict[str, list[int]]): Output of :func:`parse_lcov`.
+ project (pathlib.Path): Flutter project root holding ``lib/``.
+
+ Returns:
+ list[str]: One message per gap; empty when coverage is complete.
+ """
+ failures = [
+ f"{path}: uncovered lines {misses}"
+ for path, misses in sorted(uncovered.items())
+ if misses
+ ]
+ lib_files = {
+ str(path.relative_to(project)).replace("\\", "/")
+ for path in (project / "lib").rglob("*.dart")
+ }
+ failures.extend(
+ f"{path}: not executed by any test (0% coverage)"
+ for path in sorted(lib_files - set(uncovered))
+ )
+ return failures
+
+
+def check(project: pathlib.Path) -> int:
+ """Run the gate for one Flutter project and report to stdout/stderr.
+
+ Parameters:
+ project (pathlib.Path): Flutter project root, i.e. the directory holding
+ ``lib/`` and ``coverage/lcov.info``.
+
+ Returns:
+ int: 0 when coverage is complete, :data:`EXIT_INCOMPLETE` when it is not,
+ :data:`EXIT_NO_REPORT` when the lcov report is missing.
+ """
+ lcov = project / "coverage" / "lcov.info"
+ if not lcov.exists():
+ sys.stderr.write(f"{lcov} missing — run: flutter test --coverage\n")
+ return EXIT_NO_REPORT
+
+ uncovered = parse_lcov(lcov)
+ failures = find_failures(uncovered, project)
+ if failures:
+ sys.stderr.write("COVERAGE < 100%:\n")
+ for failure in failures:
+ sys.stderr.write(f" {failure}\n")
+ return EXIT_INCOMPLETE
+
+ sys.stdout.write(f"100% line coverage across {len(uncovered)} lib files.\n")
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Parse arguments and run the gate.
+
+ Parameters:
+ argv (list[str] | None): Argument list, or None to read ``sys.argv``.
+
+ Returns:
+ int: Process exit status.
+ """
+ parser = argparse.ArgumentParser(
+ description="Fail unless billsplit has 100% line coverage.",
+ )
+ parser.add_argument(
+ "--project",
+ type=pathlib.Path,
+ default=_DEFAULT_PROJECT,
+ help="Flutter project root (defaults to the billsplit/ app in this repo)",
+ )
+ args = parser.parse_args(argv)
+ return check(args.project)
diff --git a/python_pkg/billsplit_coverage/tests/__init__.py b/python_pkg/billsplit_coverage/tests/__init__.py
new file mode 100644
index 0000000..b20c235
--- /dev/null
+++ b/python_pkg/billsplit_coverage/tests/__init__.py
@@ -0,0 +1,3 @@
+"""Tests for the billsplit coverage gate."""
+
+from __future__ import annotations
diff --git a/python_pkg/billsplit_coverage/tests/test_checker.py b/python_pkg/billsplit_coverage/tests/test_checker.py
new file mode 100644
index 0000000..cba107f
--- /dev/null
+++ b/python_pkg/billsplit_coverage/tests/test_checker.py
@@ -0,0 +1,191 @@
+"""Tests for the billsplit coverage gate."""
+
+from __future__ import annotations
+
+import runpy
+import sys
+from typing import TYPE_CHECKING
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from python_pkg.billsplit_coverage import checker
+
+if TYPE_CHECKING:
+ import pathlib
+
+
+def make_project(
+ root: pathlib.Path,
+ lcov: str | None = None,
+ dart_files: tuple[str, ...] = ("lib/main.dart",),
+) -> pathlib.Path:
+ """Build a throwaway Flutter project tree.
+
+ Parameters:
+ root (pathlib.Path): Directory to build the project in.
+ lcov (str | None): Contents of ``coverage/lcov.info``; None writes no
+ report at all, which is the missing-report case.
+ dart_files (tuple[str, ...]): Project-relative Dart files to create.
+
+ Returns:
+ pathlib.Path: The project root.
+ """
+ for relative in dart_files:
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("void main() {}\n", encoding="utf-8")
+ if lcov is not None:
+ report = root / "coverage" / "lcov.info"
+ report.parent.mkdir(parents=True, exist_ok=True)
+ report.write_text(lcov, encoding="utf-8")
+ return root
+
+
+class TestParseLcov:
+ def test_records_only_unhit_lines(self, tmp_path: pathlib.Path) -> None:
+ report = tmp_path / "lcov.info"
+ report.write_text(
+ "SF:lib/main.dart\nDA:1,3\nDA:2,0\nDA:9,0\nend_of_record\n",
+ encoding="utf-8",
+ )
+ assert checker.parse_lcov(report) == {"lib/main.dart": [2, 9]}
+
+ def test_fully_covered_file_maps_to_empty_list(
+ self, tmp_path: pathlib.Path
+ ) -> None:
+ """Present-and-covered must be distinguishable from absent."""
+ report = tmp_path / "lcov.info"
+ report.write_text("SF:lib/a.dart\nDA:1,1\nend_of_record\n", encoding="utf-8")
+ assert checker.parse_lcov(report) == {"lib/a.dart": []}
+
+ def test_normalises_windows_separators(self, tmp_path: pathlib.Path) -> None:
+ report = tmp_path / "lcov.info"
+ report.write_text("SF:lib\\ui\\home.dart\nDA:1,1\n", encoding="utf-8")
+ assert "lib/ui/home.dart" in checker.parse_lcov(report)
+
+ def test_da_before_any_sf_is_ignored(self, tmp_path: pathlib.Path) -> None:
+ """A DA: line with no preceding SF: has no file to attribute to."""
+ report = tmp_path / "lcov.info"
+ report.write_text("DA:1,0\nSF:lib/a.dart\nDA:2,0\n", encoding="utf-8")
+ assert checker.parse_lcov(report) == {"lib/a.dart": [2]}
+
+ def test_unrelated_lines_are_skipped(self, tmp_path: pathlib.Path) -> None:
+ report = tmp_path / "lcov.info"
+ report.write_text(
+ "TN:\nSF:lib/a.dart\nLF:2\nLH:1\nDA:1,0\nend_of_record\n",
+ encoding="utf-8",
+ )
+ assert checker.parse_lcov(report) == {"lib/a.dart": [1]}
+
+ def test_empty_report_yields_nothing(self, tmp_path: pathlib.Path) -> None:
+ report = tmp_path / "lcov.info"
+ report.write_text("", encoding="utf-8")
+ assert checker.parse_lcov(report) == {}
+
+
+class TestFindFailures:
+ def test_no_gaps_returns_empty(self, tmp_path: pathlib.Path) -> None:
+ project = make_project(tmp_path, lcov="")
+ assert checker.find_failures({"lib/main.dart": []}, project) == []
+
+ def test_reports_uncovered_lines(self, tmp_path: pathlib.Path) -> None:
+ project = make_project(tmp_path, lcov="")
+ failures = checker.find_failures({"lib/main.dart": [2, 7]}, project)
+ assert failures == ["lib/main.dart: uncovered lines [2, 7]"]
+
+ def test_reports_file_absent_from_report(self, tmp_path: pathlib.Path) -> None:
+ project = make_project(
+ tmp_path, lcov="", dart_files=("lib/main.dart", "lib/orphan.dart")
+ )
+ failures = checker.find_failures({"lib/main.dart": []}, project)
+ assert failures == ["lib/orphan.dart: not executed by any test (0% coverage)"]
+
+ def test_reports_both_kinds_together(self, tmp_path: pathlib.Path) -> None:
+ project = make_project(
+ tmp_path, lcov="", dart_files=("lib/main.dart", "lib/orphan.dart")
+ )
+ failures = checker.find_failures({"lib/main.dart": [4]}, project)
+ assert failures == [
+ "lib/main.dart: uncovered lines [4]",
+ "lib/orphan.dart: not executed by any test (0% coverage)",
+ ]
+
+ def test_finds_dart_files_in_subdirectories(self, tmp_path: pathlib.Path) -> None:
+ project = make_project(
+ tmp_path, lcov="", dart_files=("lib/domain/split_engine.dart",)
+ )
+ failures = checker.find_failures({}, project)
+ assert failures == [
+ "lib/domain/split_engine.dart: not executed by any test (0% coverage)"
+ ]
+
+
+class TestCheck:
+ def test_missing_report_exits_two(
+ self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
+ ) -> None:
+ project = make_project(tmp_path)
+ assert checker.check(project) == checker.EXIT_NO_REPORT
+ assert "flutter test --coverage" in capsys.readouterr().err
+
+ def test_incomplete_coverage_exits_one_and_names_files(
+ self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
+ ) -> None:
+ project = make_project(
+ tmp_path,
+ lcov="SF:lib/main.dart\nDA:1,1\nDA:2,0\n",
+ dart_files=("lib/main.dart", "lib/orphan.dart"),
+ )
+ assert checker.check(project) == checker.EXIT_INCOMPLETE
+ err = capsys.readouterr().err
+ assert "COVERAGE < 100%:" in err
+ assert "lib/main.dart: uncovered lines [2]" in err
+ assert "lib/orphan.dart: not executed by any test" in err
+
+ def test_full_coverage_exits_zero(
+ self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
+ ) -> None:
+ project = make_project(tmp_path, lcov="SF:lib/main.dart\nDA:1,1\n")
+ assert checker.check(project) == 0
+ assert "100% line coverage across 1 lib files." in capsys.readouterr().out
+
+
+class TestMain:
+ def test_project_flag_selects_the_tree(self, tmp_path: pathlib.Path) -> None:
+ project = make_project(tmp_path, lcov="SF:lib/main.dart\nDA:1,1\n")
+ assert checker.main(["--project", str(project)]) == 0
+
+ def test_defaults_to_the_repo_billsplit_app(self) -> None:
+ """With no --project, the gate resolves billsplit/ from this file."""
+ with patch.object(checker, "check", return_value=0) as mock_check:
+ assert checker.main([]) == 0
+ assert mock_check.call_args[0][0].name == "billsplit"
+
+ def test_propagates_failure_status(self, tmp_path: pathlib.Path) -> None:
+ project = make_project(tmp_path)
+ assert checker.main(["--project", str(project)]) == checker.EXIT_NO_REPORT
+
+
+class TestModuleEntry:
+ def test_main_called_when_run_as_module(self) -> None:
+ """python3 -m python_pkg.billsplit_coverage runs main() and exits."""
+ mock_main = MagicMock(return_value=0)
+ # runpy warns if __main__ is already imported, and the suite may run in
+ # a random order, so another test could have imported it first.
+ sys.modules.pop("python_pkg.billsplit_coverage.__main__", None)
+ with (
+ patch("python_pkg.billsplit_coverage.checker.main", mock_main),
+ pytest.raises(SystemExit) as excinfo,
+ ):
+ runpy.run_module("python_pkg.billsplit_coverage", run_name="__main__")
+ assert excinfo.value.code == 0
+ mock_main.assert_called_once()
+
+ def test_main_not_called_on_plain_import(self) -> None:
+ """Importing __main__ must not run the gate as a side effect."""
+ mock_main = MagicMock()
+ with patch("python_pkg.billsplit_coverage.checker.main", mock_main):
+ sys.modules.pop("python_pkg.billsplit_coverage.__main__", None)
+ runpy.run_module("python_pkg.billsplit_coverage.__main__")
+ mock_main.assert_not_called()