()),
- );
- });
-}
diff --git a/billsplit/test/fixtures/sample_receipt.json b/billsplit/test/fixtures/sample_receipt.json
deleted file mode 100644
index de6f402c..00000000
--- a/billsplit/test/fixtures/sample_receipt.json
+++ /dev/null
@@ -1 +0,0 @@
-{"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
deleted file mode 100644
index d341bb1c..00000000
--- a/billsplit/test/helpers.dart
+++ /dev/null
@@ -1,118 +0,0 @@
-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
deleted file mode 100644
index 0cfbb723..00000000
--- a/billsplit/test/models_test.dart
+++ /dev/null
@@ -1,78 +0,0 @@
-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
deleted file mode 100644
index 7c297b12..00000000
--- a/billsplit/test/people_flows_test.dart
+++ /dev/null
@@ -1,154 +0,0 @@
-// 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
deleted file mode 100644
index 09f58809..00000000
--- a/billsplit/test/receipt_flows_test.dart
+++ /dev/null
@@ -1,140 +0,0 @@
-// 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
deleted file mode 100644
index 07059e91..00000000
--- a/billsplit/test/receipt_items_test.dart
+++ /dev/null
@@ -1,195 +0,0 @@
-// 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
deleted file mode 100644
index 9451f673..00000000
--- a/billsplit/test/split_engine_test.dart
+++ /dev/null
@@ -1,189 +0,0 @@
-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
deleted file mode 100644
index 42452909..00000000
--- a/billsplit/test/ui_flows_test.dart
+++ /dev/null
@@ -1,210 +0,0 @@
-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
deleted file mode 100644
index e37c0aba..00000000
--- a/billsplit/test/widget_test.dart
+++ /dev/null
@@ -1,16 +0,0 @@
-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
deleted file mode 100644
index f9be9de2..00000000
--- a/billsplit/test/xlsx_export_test.dart
+++ /dev/null
@@ -1,31 +0,0 @@
-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
deleted file mode 100644
index fcf912d9..00000000
--- a/billsplit/test/xlsx_extra_test.dart
+++ /dev/null
@@ -1,56 +0,0 @@
-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/docs/superpowers/contracts/archive-billsplit-2026-08-23.json b/docs/superpowers/contracts/archive-billsplit-2026-08-23.json
new file mode 100644
index 00000000..6bdea775
--- /dev/null
+++ b/docs/superpowers/contracts/archive-billsplit-2026-08-23.json
@@ -0,0 +1,21 @@
+{
+ "title": "Archive billsplit together with every artifact coupled to it",
+ "objective": "Remove the billsplit Flutter app from testsAndMisc without leaving the repo in a state where a gate fails or a config rule points at a missing path. billsplit is the coupled case the earlier archive commit deliberately deferred: a coverage package, an app_icons registry entry, a CI workflow and two paired binary rules all name it.",
+ "acceptance_criteria": [
+ "billsplit/ and python_pkg/billsplit_coverage/ are both present in kuhyx/testsAndMisc-archive, confirmed via the GitHub API before the local delete.",
+ "Archived commit count equals the monorepo count for those paths (8), and tracked file counts match exactly (73 and 5).",
+ "python_pkg/app_icons/apps.py no longer contains a billsplit AppIcon entry, and the app_icons registry still imports cleanly.",
+ "The repo-wide pytest gate still reports 100% coverage, with the test count reduced by exactly the 19 billsplit_coverage tests and nothing else.",
+ "check_no_binaries.sh exits 0 with a .binary-allowlist that now carries no patterns.",
+ ".gitignore contains no billsplit rule; the ! overrides are removed in the same commit as the allowlist patterns they paired with, so the two halves never disagree.",
+ "No file under .github/workflows/ references billsplit or billsplit_coverage.",
+ "AGENTS.md no longer presents billsplit as a live app, while keeping the both-halves guidance for any future Flutter app.",
+ "pre-commit passes without --no-verify."
+ ],
+ "out_of_scope": [
+ "Removing python_pkg/app_icons itself, which serves eleven other apps.",
+ "Changing how check_no_binaries.sh or the coverage gate work; this commit only removes data they read.",
+ "Historical planning documents that mention billsplit as past work."
+ ],
+ "verifier": "gh api confirms both paths in the archive; git ls-files counts match 73/5; diff -r clean; python3 meta/scripts/pytest_changed_packages.py python_pkg/app_icons/apps.py reports 2095 passed at 100%; ./meta/scripts/check_no_binaries.sh exits 0; ./meta/scripts/check_directory_depth.sh --all drops 451 -> 417; git grep billsplit finds only intentional prose."
+}
diff --git a/docs/superpowers/evidence/archive-billsplit-2026-08-23.json b/docs/superpowers/evidence/archive-billsplit-2026-08-23.json
new file mode 100644
index 00000000..3db05fc6
--- /dev/null
+++ b/docs/superpowers/evidence/archive-billsplit-2026-08-23.json
@@ -0,0 +1,64 @@
+{
+ "intent": "Archive the billsplit Flutter app to testsAndMisc-archive together with everything coupled to it, so the monorepo's 100% coverage gate and binary-allowlist stay consistent.",
+ "scope": [
+ "billsplit/ (73 files) - removed",
+ "python_pkg/billsplit_coverage/ (5 files) - removed, its only purpose was gating billsplit",
+ ".github/workflows/billsplit-ci.yml - removed",
+ "python_pkg/app_icons/apps.py - billsplit registry entry removed",
+ ".binary-allowlist, .gitignore, .prettierignore, AGENTS.md - billsplit rules removed",
+ "Non-goal: app_icons itself stays; only its billsplit entry goes"
+ ],
+ "changes": [
+ "Extracted billsplit/ and python_pkg/billsplit_coverage/ in one filter-repo pass (8 commits, matching the monorepo's count for those paths) and merged into kuhyx/testsAndMisc-archive, where billsplit_coverage joins the existing python_pkg/ tree.",
+ "Deleted the app_icons AppIcon entry keyed 'billsplit', whose repo= pointed at _HOME/testsAndMisc/billsplit. app_icons stays in the repo and its tests iterate APPS generically, so a dangling entry would have failed the suite.",
+ "Emptied .binary-allowlist: billsplit's launcher icons were its only entries. The policy header is kept and now records that no exception is in force.",
+ "Removed the 27-line billsplit block from .gitignore (the ! overrides that paired with the allowlist) and the .prettierignore fixture line.",
+ "Rewrote the AGENTS.md 'Adding a Flutter app' section: the both-halves guidance is still correct for a future app, but no longer claims billsplit exists, and notes a Flutter app now belongs in its own repo."
+ ],
+ "verification": [
+ {
+ "command": "git ls-files | wc -l, archive vs source",
+ "result": "pass",
+ "evidence": "billsplit 73 = 73, python_pkg/billsplit_coverage 5 = 5; 8 commits, equal to git log --oneline -- billsplit python_pkg/billsplit_coverage."
+ },
+ {
+ "command": "diff -r --brief between archived copy and working tree",
+ "result": "pass",
+ "evidence": "No content differences for either path."
+ },
+ {
+ "command": "gh api repos/kuhyx/testsAndMisc-archive/contents --jq '.[].name'",
+ "result": "pass",
+ "evidence": "billsplit listed; billsplit_coverage confirmed under the archive's python_pkg/ alongside the previously archived packages."
+ },
+ {
+ "command": "python3 meta/scripts/pytest_changed_packages.py python_pkg/app_icons/apps.py",
+ "result": "pass",
+ "evidence": "2095 passed, TOTAL coverage 100.00% - the gate holds after removing the registry entry. Count fell from 2114 by exactly the 19 billsplit_coverage tests."
+ },
+ {
+ "command": "PYTHONPATH=. python3 -c 'from python_pkg.app_icons import apps; ...'",
+ "result": "pass",
+ "evidence": "Registry loads with 12 apps and 'billsplit' in apps.APPS is False. Seven other apps still carry linux=False, so no cli.py branch lost its only exercise."
+ },
+ {
+ "command": "./meta/scripts/check_no_binaries.sh",
+ "result": "pass",
+ "evidence": "Exit 0 with an entry-free .binary-allowlist - the checker tolerates a comment-only file."
+ },
+ {
+ "command": "./meta/scripts/check_directory_depth.sh --all",
+ "result": "pass (improvement)",
+ "evidence": "451 -> 417 violations, matching billsplit's 34 deep files."
+ }
+ ],
+ "risks": [
+ "app_icons' generator can no longer produce billsplit's icons; that is intended, since the app and its assets now live in the archive.",
+ ".binary-allowlist is now empty of patterns. If a future Flutter app is added, both it and the .gitignore ! overrides must be repopulated - AGENTS.md still documents that requirement.",
+ "The archive push happened before the local delete; presence was confirmed through the GitHub API rather than assumed."
+ ],
+ "rollback": [
+ "git revert the removal commit to restore all four trees and the config blocks.",
+ "Re-run meta/scripts/pytest_changed_packages.py to confirm the coverage gate is still satisfied after the revert."
+ ]
+}
diff --git a/python_pkg/app_icons/apps.py b/python_pkg/app_icons/apps.py
index b6fd4008..bf7777ce 100644
--- a/python_pkg/app_icons/apps.py
+++ b/python_pkg/app_icons/apps.py
@@ -115,20 +115,6 @@ APPS: Final[dict[str, AppIcon]] = {
icon_name="home-inventory",
linux=True,
),
- AppIcon(
- key="billsplit",
- # The only app in this registry that lives inside the testsAndMisc
- # monorepo rather than in its own repo, so its path carries a
- # directory component the others do not.
- repo=_HOME / "testsAndMisc" / "billsplit",
- accent="#B8862E",
- glyph="receipt-split",
- icon_name="billsplit",
- # `flutter create` scaffolded a linux/ target, but the app is
- # phone-first and ships no desktop entry, so the hicolor icon set
- # is not generated for it.
- linux=False,
- ),
AppIcon(
key="epopeja_karta",
repo=_HOME / "epopeja_karta",
diff --git a/python_pkg/billsplit_coverage/__init__.py b/python_pkg/billsplit_coverage/__init__.py
deleted file mode 100644
index 3f7e0065..00000000
--- a/python_pkg/billsplit_coverage/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-"""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
deleted file mode 100644
index 9e959078..00000000
--- a/python_pkg/billsplit_coverage/__main__.py
+++ /dev/null
@@ -1,14 +0,0 @@
-"""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
deleted file mode 100644
index d0766712..00000000
--- a/python_pkg/billsplit_coverage/checker.py
+++ /dev/null
@@ -1,130 +0,0 @@
-"""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
deleted file mode 100644
index b20c2352..00000000
--- a/python_pkg/billsplit_coverage/tests/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""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
deleted file mode 100644
index cba107f0..00000000
--- a/python_pkg/billsplit_coverage/tests/test_checker.py
+++ /dev/null
@@ -1,191 +0,0 @@
-"""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()