diff --git a/package.json b/package.json index 68ae134..bb40303 100644 --- a/package.json +++ b/package.json @@ -46,10 +46,12 @@ "zod": "^3.22.4" }, "devDependencies": { + "@types/jest-image-snapshot": "^6.4.1", "@types/node": "^20.10.0", "@typescript/native-preview": "7.0.0-dev.20260210.1", "@vitest/coverage-v8": "^4.0.16", "husky": "^9.1.7", + "jest-image-snapshot": "^6.5.1", "lint-staged": "^16.2.7", "oxfmt": "^0.18.0", "oxlint": "^1.33.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c32e1d..d315b3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: specifier: ^3.22.4 version: 3.25.76 devDependencies: + '@types/jest-image-snapshot': + specifier: ^6.4.1 + version: 6.4.1 '@types/node': specifier: ^20.10.0 version: 20.19.26 @@ -57,6 +60,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + jest-image-snapshot: + specifier: ^6.5.1 + version: 6.5.1 lint-staged: specifier: ^16.2.7 version: 16.2.7 @@ -75,6 +81,10 @@ importers: packages: + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -252,6 +262,30 @@ packages: cpu: [x64] os: [win32] + '@jest/diff-sequences@30.0.1': + resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.2.0': + resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.0.1': + resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/types@30.2.0': + resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -587,6 +621,9 @@ packages: cpu: [x64] os: [win32] + '@sinclair/typebox@0.34.48': + resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -599,9 +636,36 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest-image-snapshot@6.4.1': + resolution: {integrity: sha512-pj3Sdc7Cx5mMLUttPprazSDQCur2cr512Dm38e9aAHI55LDxEhqdyqzK9myC4EmEy7sPAF2nGJ8zifX4qso7sQ==} + + '@types/jest@30.0.0': + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/node@20.19.26': resolution: {integrity: sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==} + '@types/pixelmatch@5.2.6': + resolution: {integrity: sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260210.1': resolution: {integrity: sha512-taEYpsrCbdcyHkqNMBiVcqKR7ZHMC1jwTBM9kn3eUgOjXn68ASRrmyzYBdrujluBJMO7rl+Gm5QRT68onYt53A==} cpu: [arm64] @@ -687,6 +751,14 @@ packages: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -719,6 +791,10 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -726,6 +802,10 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -738,6 +818,13 @@ packages: resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} engines: {node: '>=20'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -791,6 +878,10 @@ packages: engines: {node: '>=18'} hasBin: true + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -805,6 +896,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + expect@30.2.0: + resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -835,12 +930,22 @@ packages: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} + get-stdin@5.0.1: + resolution: {integrity: sha512-jZV7n6jGE3Gt7fgSTJoz91Ak5MuTLwMwkoYdjxuJ/AmjIsE1UC03y/IWkZCQGEvVNS9qoRNwy5BCqxImv0FVeA==} + engines: {node: '>=0.12.0'} + get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glur@1.1.2: + resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -909,6 +1014,42 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jest-diff@30.2.0: + resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-image-snapshot@6.5.1: + resolution: {integrity: sha512-xlJFufgfY2Z4DsRsjcnTwxuynvo1bKdhf4OfcEftNuUAK+BwSCUtPmwlBGJhQ0XJXfm9JMAi/4BhQiHbaV8HrA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + jest: '>=20 <=29' + peerDependenciesMeta: + jest: + optional: true + + jest-matcher-utils@30.2.0: + resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@30.2.0: + resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock@30.2.0: + resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-regex-util@30.0.1: + resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-util@30.2.0: + resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} @@ -927,6 +1068,9 @@ packages: resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} engines: {node: '>=20.0.0'} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} @@ -1055,6 +1199,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + pixelmatch@5.3.0: + resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} + hasBin: true + playwright-core@1.57.0: resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} engines: {node: '>=18'} @@ -1065,6 +1213,14 @@ packages: engines: {node: '>=18'} hasBin: true + pngjs@3.4.0: + resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} + engines: {node: '>=4.0.0'} + + pngjs@6.0.0: + resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} + engines: {node: '>=12.13.0'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1074,6 +1230,10 @@ packages: engines: {node: '>=10'} hasBin: true + pretty-format@30.2.0: + resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -1084,6 +1244,9 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -1133,6 +1296,10 @@ packages: simple-git@3.30.0: resolution: {integrity: sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg==} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} @@ -1145,6 +1312,13 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + ssim.js@3.5.0: + resolution: {integrity: sha512-Aj6Jl2z6oDmgYFFbQqK7fght19bXdOxY7Tj03nF+03M9gCBAjeIiO8/PlEGMfKDwYpw4q6iBqVq2YuREorGg/g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1337,6 +1511,12 @@ packages: snapshots: + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -1430,6 +1610,33 @@ snapshots: '@esbuild/win32-x64@0.27.1': optional: true + '@jest/diff-sequences@30.0.1': {} + + '@jest/expect-utils@30.2.0': + dependencies: + '@jest/get-type': 30.1.0 + + '@jest/get-type@30.1.0': {} + + '@jest/pattern@30.0.1': + dependencies: + '@types/node': 20.19.26 + jest-regex-util: 30.0.1 + + '@jest/schemas@30.0.5': + dependencies: + '@sinclair/typebox': 0.34.48 + + '@jest/types@30.2.0': + dependencies: + '@jest/pattern': 30.0.1 + '@jest/schemas': 30.0.5 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.19.26 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1635,6 +1842,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true + '@sinclair/typebox@0.34.48': {} + '@standard-schema/spec@1.1.0': {} '@types/chai@5.2.3': @@ -1646,10 +1855,43 @@ snapshots: '@types/estree@1.0.8': {} + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest-image-snapshot@6.4.1': + dependencies: + '@types/jest': 30.0.0 + '@types/pixelmatch': 5.2.6 + ssim.js: 3.5.0 + + '@types/jest@30.0.0': + dependencies: + expect: 30.2.0 + pretty-format: 30.2.0 + '@types/node@20.19.26': dependencies: undici-types: 6.21.0 + '@types/pixelmatch@5.2.6': + dependencies: + '@types/node': 20.19.26 + + '@types/stack-utils@2.0.3': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260210.1': optional: true @@ -1743,6 +1985,12 @@ snapshots: ansi-regex@6.2.2: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} assertion-error@2.0.1: {} @@ -1777,10 +2025,17 @@ snapshots: chai@6.2.2: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@5.6.2: {} chownr@1.1.4: {} + ci-info@4.4.0: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -1792,6 +2047,12 @@ snapshots: slice-ansi: 7.1.2 string-width: 8.1.0 + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + colorette@2.0.20: {} commander@12.1.0: {} @@ -1851,6 +2112,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.1 '@esbuild/win32-x64': 0.27.1 + escape-string-regexp@2.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -1861,6 +2124,15 @@ snapshots: expect-type@1.3.0: {} + expect@30.2.0: + dependencies: + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -1879,6 +2151,8 @@ snapshots: get-east-asian-width@1.4.0: {} + get-stdin@5.0.1: {} + get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -1886,6 +2160,10 @@ snapshots: github-from-package@0.0.0: {} + glur@1.1.2: {} + + graceful-fs@4.2.11: {} + handlebars@4.7.8: dependencies: minimist: 1.2.8 @@ -1944,6 +2222,61 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jest-diff@30.2.0: + dependencies: + '@jest/diff-sequences': 30.0.1 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.2.0 + + jest-image-snapshot@6.5.1: + dependencies: + chalk: 4.1.2 + get-stdin: 5.0.1 + glur: 1.1.2 + lodash: 4.17.23 + pixelmatch: 5.3.0 + pngjs: 3.4.0 + ssim.js: 3.5.0 + + jest-matcher-utils@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.2.0 + pretty-format: 30.2.0 + + jest-message-util@30.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 30.2.0 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 20.19.26 + jest-util: 30.2.0 + + jest-regex-util@30.0.1: {} + + jest-util@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 20.19.26 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.3 + + js-tokens@4.0.0: {} + js-tokens@9.0.1: {} jszip@3.10.1: @@ -1976,6 +2309,8 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + lodash@4.17.23: {} + log-symbols@6.0.0: dependencies: chalk: 5.6.2 @@ -2115,6 +2450,10 @@ snapshots: pidtree@0.6.0: {} + pixelmatch@5.3.0: + dependencies: + pngjs: 6.0.0 + playwright-core@1.57.0: {} playwright@1.57.0: @@ -2123,6 +2462,10 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@3.4.0: {} + + pngjs@6.0.0: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2144,6 +2487,12 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 + pretty-format@30.2.0: + dependencies: + '@jest/schemas': 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + process-nextick-args@2.0.1: {} pump@3.0.3: @@ -2158,6 +2507,8 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-is@18.3.1: {} + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -2238,6 +2589,8 @@ snapshots: transitivePeerDependencies: - supports-color + slash@3.0.0: {} + slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.3 @@ -2247,6 +2600,12 @@ snapshots: source-map@0.6.1: {} + ssim.js@3.5.0: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} std-env@3.10.0: {} diff --git a/src/ballots/__image_snapshots__/fixture-proof-ballot-style-1_en-page-1.png b/src/ballots/__image_snapshots__/fixture-proof-ballot-style-1_en-page-1.png new file mode 100644 index 0000000..ab6a765 Binary files /dev/null and b/src/ballots/__image_snapshots__/fixture-proof-ballot-style-1_en-page-1.png differ diff --git a/src/ballots/__image_snapshots__/fixture-proof-ballot-style-1_en-page-2.png b/src/ballots/__image_snapshots__/fixture-proof-ballot-style-1_en-page-2.png new file mode 100644 index 0000000..11e8a4c Binary files /dev/null and b/src/ballots/__image_snapshots__/fixture-proof-ballot-style-1_en-page-2.png differ diff --git a/src/ballots/__image_snapshots__/fixture-proof-ballot-style-2_en-page-1.png b/src/ballots/__image_snapshots__/fixture-proof-ballot-style-2_en-page-1.png new file mode 100644 index 0000000..f0f460e Binary files /dev/null and b/src/ballots/__image_snapshots__/fixture-proof-ballot-style-2_en-page-1.png differ diff --git a/src/ballots/__image_snapshots__/fixture-proof-ballot-style-2_en-page-2.png b/src/ballots/__image_snapshots__/fixture-proof-ballot-style-2_en-page-2.png new file mode 100644 index 0000000..7ed945f Binary files /dev/null and b/src/ballots/__image_snapshots__/fixture-proof-ballot-style-2_en-page-2.png differ diff --git a/src/ballots/__image_snapshots__/fixture-proof-ballot-style-2_en-page-3.png b/src/ballots/__image_snapshots__/fixture-proof-ballot-style-2_en-page-3.png new file mode 100644 index 0000000..2ed3bc6 Binary files /dev/null and b/src/ballots/__image_snapshots__/fixture-proof-ballot-style-2_en-page-3.png differ diff --git a/src/ballots/__image_snapshots__/fixture-proof-ballot-style-2_en-page-4.png b/src/ballots/__image_snapshots__/fixture-proof-ballot-style-2_en-page-4.png new file mode 100644 index 0000000..8c9fdd2 Binary files /dev/null and b/src/ballots/__image_snapshots__/fixture-proof-ballot-style-2_en-page-4.png differ diff --git a/src/ballots/proof-ballot.test.ts b/src/ballots/proof-ballot.test.ts new file mode 100644 index 0000000..226d7d5 --- /dev/null +++ b/src/ballots/proof-ballot.test.ts @@ -0,0 +1,419 @@ +import { describe, test, expect } from 'vitest'; +import { PDFDocument } from 'pdf-lib'; +import { join } from 'node:path'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { generateProofBallot, getPageGeometry, gridToPdf, getOptionLabel } from './proof-ballot.js'; +import { loadElectionPackage } from './election-loader.js'; +import { expectToMatchPdfSnapshot } from '../test/pdf-snapshot.js'; +import type { + Election, + CandidateContest, + YesNoContest, + GridPositionOption, + GridPositionWriteIn, + GridPosition, + ElectionPackage, + ElectionDefinition, + BallotPdfInfo, +} from './election-loader.js'; + +const IN = 72; + +function createTestElection( + contests: (CandidateContest | YesNoContest)[], + gridPositions: GridPosition[] = [], +): Election { + return { + title: 'Test Election', + state: 'CA', + county: { id: 'county-1', name: 'Test County' }, + date: '2024-11-05', + type: 'general', + ballotStyles: [ + { + id: 'ballot-style-1', + precincts: ['precinct-1'], + districts: contests.map((c) => c.districtId), + }, + ], + precincts: [{ id: 'precinct-1', name: 'Precinct 1' }], + contests, + ballotLayout: { + paperSize: 'letter', + metadataEncoding: 'qr-code', + }, + gridLayouts: [ + { + ballotStyleId: 'ballot-style-1', + optionBoundsFromTargetMark: { x: 0, y: 0, width: 1, height: 1 }, + gridPositions, + }, + ], + }; +} + +async function createBlankPdf({ pageCount = 2 }: { pageCount?: number } = {}): Promise { + const doc = await PDFDocument.create(); + for (let i = 0; i < pageCount; i += 1) { + doc.addPage([8.5 * IN, 11 * IN]); + } + return doc.save(); +} + +describe('getPageGeometry', () => { + test('compute letter geometry', () => { + const geo = getPageGeometry('letter'); + + expect(geo.markCountX).toBe(34); + expect(geo.markCountY).toBe(41); + expect(geo.pageHeight).toBe(11 * IN); + expect(geo.originX).toBeCloseTo(0.19685 * IN + (0.1875 * IN) / 2); + expect(geo.originY).toBeCloseTo(0.16667 * IN + (0.0625 * IN) / 2); + expect(geo.gridWidth).toBeGreaterThan(0); + expect(geo.gridHeight).toBeGreaterThan(0); + }); + + test('compute legal geometry', () => { + const geo = getPageGeometry('legal'); + + expect(geo.markCountX).toBe(34); + expect(geo.markCountY).toBe(53); + expect(geo.pageHeight).toBe(14 * IN); + }); + + test('throw for unsupported paper size', () => { + expect(() => getPageGeometry('tabloid')).toThrow('Unsupported paper size: tabloid'); + }); + + test('grid dimensions are consistent with page size minus margins and marks', () => { + const geo = getPageGeometry('letter'); + const expectedWidth = 8.5 * IN - 2 * 0.19685 * IN - 0.1875 * IN; + const expectedHeight = 11 * IN - 2 * 0.16667 * IN - 0.0625 * IN; + + expect(geo.gridWidth).toBeCloseTo(expectedWidth, 5); + expect(geo.gridHeight).toBeCloseTo(expectedHeight, 5); + }); +}); + +describe('gridToPdf', () => { + test('top-left corner maps to origin with y flipped', () => { + const geo = getPageGeometry('letter'); + const { x, y } = gridToPdf(0, 0, geo); + + expect(x).toBeCloseTo(geo.originX); + expect(y).toBeCloseTo(geo.pageHeight - geo.originY); + }); + + test('bottom-right corner maps to origin + grid size', () => { + const geo = getPageGeometry('letter'); + const { x, y } = gridToPdf(geo.markCountX - 1, geo.markCountY - 1, geo); + + expect(x).toBeCloseTo(geo.originX + geo.gridWidth); + expect(y).toBeCloseTo(geo.pageHeight - geo.originY - geo.gridHeight); + }); + + test('midpoint maps to center of grid', () => { + const geo = getPageGeometry('letter'); + const midCol = (geo.markCountX - 1) / 2; + const midRow = (geo.markCountY - 1) / 2; + const { x, y } = gridToPdf(midCol, midRow, geo); + + expect(x).toBeCloseTo(geo.originX + geo.gridWidth / 2); + expect(y).toBeCloseTo(geo.pageHeight - geo.originY - geo.gridHeight / 2); + }); +}); + +describe('getOptionLabel', () => { + const candidateContest: CandidateContest = { + type: 'candidate', + id: 'mayor', + title: 'Mayor', + seats: 1, + candidates: [ + { id: 'alice', name: 'Alice Smith' }, + { id: 'bob', name: 'Bob Jones' }, + ], + allowWriteIns: true, + districtId: 'district-1', + }; + + const yesNoContest: YesNoContest = { + type: 'yesno', + id: 'measure-a', + title: 'Measure A', + yesOption: { id: 'yes-a', label: 'Yes on A' }, + noOption: { id: 'no-a', label: 'No on A' }, + districtId: 'district-1', + }; + + const election = createTestElection([candidateContest, yesNoContest]); + + test('return candidate name for option grid position', () => { + const gp: GridPositionOption = { + type: 'option', + sheetNumber: 1, + side: 'front', + column: 10, + row: 20, + contestId: 'mayor', + optionId: 'alice', + }; + expect(getOptionLabel(election, gp)).toBe('Alice Smith'); + }); + + test('return optionId when candidate not found', () => { + const gp: GridPositionOption = { + type: 'option', + sheetNumber: 1, + side: 'front', + column: 10, + row: 20, + contestId: 'mayor', + optionId: 'unknown-candidate', + }; + expect(getOptionLabel(election, gp)).toBe('unknown-candidate'); + }); + + test('return write-in label for write-in grid position', () => { + const gp: GridPositionWriteIn = { + type: 'write-in', + sheetNumber: 1, + side: 'front', + column: 10, + row: 25, + contestId: 'mayor', + writeInIndex: 0, + writeInArea: { x: 5, y: 24, width: 20, height: 2 }, + }; + expect(getOptionLabel(election, gp)).toBe('Write-in #1'); + }); + + test('return yes/no option labels', () => { + const yesGp: GridPositionOption = { + type: 'option', + sheetNumber: 1, + side: 'front', + column: 10, + row: 30, + contestId: 'measure-a', + optionId: 'yes-a', + }; + const noGp: GridPositionOption = { + type: 'option', + sheetNumber: 1, + side: 'front', + column: 10, + row: 31, + contestId: 'measure-a', + optionId: 'no-a', + }; + expect(getOptionLabel(election, yesGp)).toBe('Yes on A'); + expect(getOptionLabel(election, noGp)).toBe('No on A'); + }); + + test('return contestId when contest not found', () => { + const gp: GridPositionOption = { + type: 'option', + sheetNumber: 1, + side: 'front', + column: 10, + row: 20, + contestId: 'nonexistent', + optionId: 'whatever', + }; + expect(getOptionLabel(election, gp)).toBe('nonexistent'); + }); +}); + +describe('generateProofBallot', () => { + const candidateContest: CandidateContest = { + type: 'candidate', + id: 'mayor', + title: 'Mayor', + seats: 2, + candidates: [ + { id: 'alice', name: 'Alice Smith' }, + { id: 'bob', name: 'Bob Jones' }, + ], + allowWriteIns: true, + districtId: 'district-1', + }; + + const gridPositions: GridPosition[] = [ + { + type: 'option', + sheetNumber: 1, + side: 'front', + column: 10, + row: 10, + contestId: 'mayor', + optionId: 'alice', + }, + { + type: 'option', + sheetNumber: 1, + side: 'front', + column: 10, + row: 12, + contestId: 'mayor', + optionId: 'bob', + }, + { + type: 'write-in', + sheetNumber: 1, + side: 'front', + column: 10, + row: 14, + contestId: 'mayor', + writeInIndex: 0, + writeInArea: { x: 11, y: 13, width: 20, height: 2 }, + }, + { + type: 'option', + sheetNumber: 1, + side: 'back', + column: 10, + row: 10, + contestId: 'measure-a', + optionId: 'yes-a', + }, + { + type: 'option', + sheetNumber: 1, + side: 'back', + column: 10, + row: 12, + contestId: 'measure-a', + optionId: 'no-a', + }, + ]; + + test('throw when grid layout not found', async () => { + const election = createTestElection([candidateContest], gridPositions); + const basePdf = await createBlankPdf({ pageCount: 2 }); + + await expect(generateProofBallot(election, 'nonexistent-style', basePdf)).rejects.toThrow( + 'No grid layout found for ballot style: nonexistent-style', + ); + }); + + test('handle pages with no grid positions', async () => { + const frontOnlyPositions: GridPosition[] = [ + { + type: 'option', + sheetNumber: 1, + side: 'front', + column: 10, + row: 10, + contestId: 'mayor', + optionId: 'alice', + }, + ]; + const election = createTestElection([candidateContest], frontOnlyPositions); + const basePdf = await createBlankPdf({ pageCount: 2 }); + const proofPdf = await generateProofBallot(election, 'ballot-style-1', basePdf); + + const doc = await PDFDocument.load(proofPdf); + expect(doc.getPageCount()).toBe(2); + }); + + test('preserve original page dimensions', async () => { + const election = createTestElection( + [candidateContest], + [ + { + type: 'option', + sheetNumber: 1, + side: 'front', + column: 5, + row: 5, + contestId: 'mayor', + optionId: 'alice', + }, + ], + ); + const basePdf = await createBlankPdf({ pageCount: 1 }); + const proofPdf = await generateProofBallot(election, 'ballot-style-1', basePdf); + + const originalDoc = await PDFDocument.load(basePdf); + const proofDoc = await PDFDocument.load(proofPdf); + + const originalPage = originalDoc.getPage(0); + const proofPage = proofDoc.getPage(0); + + expect(proofPage.getWidth()).toBe(originalPage.getWidth()); + expect(proofPage.getHeight()).toBe(originalPage.getHeight()); + }); +}); + +const FIXTURE_PATH = join( + import.meta.dirname, + '../../test-fixtures/election-package-and-ballots-e71c80e-c4446e7.zip', +); + +const prooftest = test.extend<{ + tmp: string; + electionPackage: ElectionPackage; + electionDefinition: ElectionDefinition; + election: Election; + ballotStyle1: BallotPdfInfo; + ballotStyle2: BallotPdfInfo; +}>({ + // eslint-disable-next-line no-empty-pattern + tmp: async ({}, use) => { + const path = await mkdtemp(join(tmpdir(), 'vx-qa-proof-test-')); + await use(path); + await rm(path, { recursive: true, force: true }); + }, + electionPackage: async ({ tmp }, use) => + use((await loadElectionPackage(FIXTURE_PATH, tmp)).electionPackage), + electionDefinition: ({ electionPackage }, use) => use(electionPackage.electionDefinition), + election: ({ electionDefinition }, use) => use(electionDefinition.election), + ballotStyle1: ({ electionPackage }, use) => + use( + electionPackage.ballots.find( + (b) => + b.ballotStyleId === '1_en' && b.ballotMode === 'official' && b.ballotType === 'precinct', + )!, + ), + ballotStyle2: ({ electionPackage }, use) => + use( + electionPackage.ballots.find( + (b) => + b.ballotStyleId === '2_en' && b.ballotMode === 'official' && b.ballotType === 'precinct', + )!, + ), +}); + +describe('generateProofBallot with real election fixture', async () => { + prooftest('ballot style 1_en (2 pages)', async ({ election, ballotStyle1 }) => { + const proofPdf = await generateProofBallot( + election, + ballotStyle1.ballotStyleId, + ballotStyle1.pdfData, + ); + + const doc = await PDFDocument.load(proofPdf); + expect(doc.getPageCount()).toBe(2); + + await expectToMatchPdfSnapshot(proofPdf, { + customSnapshotIdentifier: 'fixture-proof-ballot-style-1_en', + }); + }); + + prooftest('ballot style 2_en (4 pages)', async ({ election, ballotStyle2 }) => { + const proofPdf = await generateProofBallot( + election, + ballotStyle2.ballotStyleId, + ballotStyle2.pdfData, + ); + + const doc = await PDFDocument.load(proofPdf); + expect(doc.getPageCount()).toBe(4); + + await expectToMatchPdfSnapshot(proofPdf, { + customSnapshotIdentifier: 'fixture-proof-ballot-style-2_en', + }); + }); +}); diff --git a/src/ballots/proof-ballot.ts b/src/ballots/proof-ballot.ts new file mode 100644 index 0000000..87314ac --- /dev/null +++ b/src/ballots/proof-ballot.ts @@ -0,0 +1,284 @@ +/** + * Proof ballot generation - overlay contest/candidate labels on ballot PDFs + * for visual verification of bubble-to-contest mapping. + */ + +import { PDFDocument, StandardFonts, rgb, type PDFFont, type PDFPage } from 'pdf-lib'; +import type { Election, GridPosition } from './election-loader.js'; + +const IN = 72; // PDF points per inch + +const PAGE_MARGINS = { left: 0.19685 * IN, top: 0.16667 * IN } as const; +const TIMING_MARK = { width: 0.1875 * IN, height: 0.0625 * IN } as const; + +export interface PageGeometry { + readonly originX: number; + readonly originY: number; + readonly gridWidth: number; + readonly gridHeight: number; + readonly markCountX: number; + readonly markCountY: number; + readonly pageHeight: number; +} + +interface Fonts { + readonly regular: PDFFont; + readonly bold: PDFFont; +} + +const PAPER_SIZES: Record = { + letter: { width: 8.5, height: 11 }, + legal: { width: 8.5, height: 14 }, + custom17: { width: 8.5, height: 17 }, + custom18: { width: 8.5, height: 18 }, + custom22: { width: 8.5, height: 22 }, +}; + +export function getPageGeometry(paperSize: string): PageGeometry { + const size = PAPER_SIZES[paperSize]; + if (!size) { + throw new Error(`Unsupported paper size: ${paperSize}`); + } + + const pageWidthPt = size.width * IN; + const pageHeightPt = size.height * IN; + + const markCountX = size.width * 4; + const markCountY = size.height * 4 - 3; + + const originX = PAGE_MARGINS.left + TIMING_MARK.width / 2; + const originY = PAGE_MARGINS.top + TIMING_MARK.height / 2; + + const gridWidth = pageWidthPt - 2 * PAGE_MARGINS.left - TIMING_MARK.width; + const gridHeight = pageHeightPt - 2 * PAGE_MARGINS.top - TIMING_MARK.height; + + return { + originX, + originY, + gridWidth, + gridHeight, + markCountX, + markCountY, + pageHeight: pageHeightPt, + }; +} + +export function gridToPdf( + column: number, + row: number, + geometry: PageGeometry, +): { x: number; y: number } { + const x = geometry.originX + (column / (geometry.markCountX - 1)) * geometry.gridWidth; + // PDF y-axis is bottom-up, grid row 0 is at top + const topDownY = geometry.originY + (row / (geometry.markCountY - 1)) * geometry.gridHeight; + const y = geometry.pageHeight - topDownY; + return { x, y }; +} + +function fitText( + text: string, + maxWidth: number, + font: PDFFont, + maxSize: number, + minSize: number, +): { text: string; fontSize: number } { + for (let size = maxSize; size >= minSize; size -= 0.5) { + const width = font.widthOfTextAtSize(text, size); + if (width <= maxWidth) { + return { text, fontSize: size }; + } + } + + // Truncate with ellipsis at minimum size + let truncated = text; + while (truncated.length > 1) { + truncated = truncated.slice(0, -1); + const width = font.widthOfTextAtSize(`${truncated}…`, minSize); + if (width <= maxWidth) { + return { text: `${truncated}…`, fontSize: minSize }; + } + } + + return { text: '…', fontSize: minSize }; +} + +export function getOptionLabel(election: Election, gridPosition: GridPosition): string { + const contest = election.contests.find((c) => c.id === gridPosition.contestId); + if (!contest) return gridPosition.contestId; + + if (gridPosition.type === 'write-in') { + return `Write-in #${gridPosition.writeInIndex + 1}`; + } + + if (contest.type === 'candidate') { + const candidate = contest.candidates.find((c) => c.id === gridPosition.optionId); + return candidate?.name ?? gridPosition.optionId; + } + + if (contest.type === 'yesno') { + if (gridPosition.optionId === contest.yesOption.id) return contest.yesOption.label; + if (gridPosition.optionId === contest.noOption.id) return contest.noOption.label; + return gridPosition.optionId; + } + + return gridPosition.optionId; +} + +function addProofAnnotationsToPage( + page: PDFPage, + gridPositions: GridPosition[], + geometry: PageGeometry, + fonts: Fonts, + election: Election, +): void { + const labelMaxWidth = 120; + const labelPadding = 2; + + for (const gp of gridPositions) { + const { x, y } = gridToPdf(gp.column, gp.row, geometry); + + // Draw red X at bubble position + const xSize = 3; + const xColor = rgb(1, 0, 0); + page.drawLine({ + start: { x: x - xSize, y: y - xSize }, + end: { x: x + xSize, y: y + xSize }, + thickness: 1, + color: xColor, + }); + page.drawLine({ + start: { x: x - xSize, y: y + xSize }, + end: { x: x + xSize, y: y - xSize }, + thickness: 1, + color: xColor, + }); + + // Get label text + const contest = election.contests.find((c) => c.id === gp.contestId); + const contestTitle = contest?.title ?? gp.contestId; + const optionLabel = getOptionLabel(election, gp); + + const optionFit = fitText(optionLabel, labelMaxWidth - 2 * labelPadding, fonts.bold, 7, 4); + const contestFit = fitText( + contestTitle, + labelMaxWidth - 2 * labelPadding, + fonts.regular, + 5.5, + 3.5, + ); + + if (gp.type !== 'write-in') { + const lineHeight = 1.2; + const optionHeight = optionFit.fontSize * lineHeight; + const contestHeight = contestFit.fontSize * lineHeight; + const boxHeight = optionHeight + contestHeight + 2 * labelPadding; + const boxX = x - labelMaxWidth - 8; + const boxY = y - boxHeight / 2; + + // Draw label background + page.drawRectangle({ + x: boxX, + y: boxY, + width: labelMaxWidth, + height: boxHeight, + color: rgb(0.85, 1, 0.85), + opacity: 0.8, + borderColor: rgb(0, 0.5, 0), + borderWidth: 0.5, + }); + + // Draw option name (bold, top) + page.drawText(optionFit.text, { + x: boxX + labelPadding, + y: boxY + boxHeight - labelPadding - optionFit.fontSize, + size: optionFit.fontSize, + font: fonts.bold, + color: rgb(0, 0, 0), + }); + + // Draw contest title (regular, bottom) + page.drawText(contestFit.text, { + x: boxX + labelPadding, + y: boxY + labelPadding, + size: contestFit.fontSize, + font: fonts.regular, + color: rgb(0.3, 0.3, 0.3), + }); + } else { + // Draw write-in area overlay + const writeInArea = gp.writeInArea; + const topLeft = gridToPdf(writeInArea.x, writeInArea.y, geometry); + const bottomRight = gridToPdf( + writeInArea.x + writeInArea.width, + writeInArea.y + writeInArea.height, + geometry, + ); + + const rectX = topLeft.x; + const rectY = bottomRight.y; + const rectWidth = bottomRight.x - topLeft.x; + const rectHeight = topLeft.y - bottomRight.y; + + page.drawRectangle({ + x: rectX, + y: rectY, + width: rectWidth, + height: rectHeight, + color: rgb(0.96, 0.87, 0.7), + opacity: 0.5, + borderColor: rgb(0.7, 0.5, 0.2), + borderWidth: 0.5, + }); + + const wiLabel = fitText( + `Write-in #${gp.writeInIndex + 1} — ${contestTitle}`, + rectWidth - 4, + fonts.regular, + 6, + 3.5, + ); + + page.drawText(wiLabel.text, { + x: rectX + 2, + y: rectY + rectHeight - wiLabel.fontSize - 2, + size: wiLabel.fontSize, + font: fonts.regular, + color: rgb(0.4, 0.3, 0.1), + }); + } + } +} + +export async function generateProofBallot( + election: Election, + ballotStyleId: string, + baseBallotPdf: Uint8Array, +): Promise { + const gridLayout = election.gridLayouts?.find((gl) => gl.ballotStyleId === ballotStyleId); + if (!gridLayout) { + throw new Error(`No grid layout found for ballot style: ${ballotStyleId}`); + } + + const geometry = getPageGeometry(election.ballotLayout.paperSize); + const pdfDoc = await PDFDocument.load(baseBallotPdf); + const regular = await pdfDoc.embedFont(StandardFonts.Helvetica); + const bold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const fonts: Fonts = { regular, bold }; + + const pages = pdfDoc.getPages(); + + // Group grid positions by page (sheetNumber + side) + for (let pageIndex = 0; pageIndex < pages.length; pageIndex += 1) { + const sheetNumber = Math.floor(pageIndex / 2) + 1; + const side = pageIndex % 2 === 0 ? 'front' : 'back'; + const pagePositions = gridLayout.gridPositions.filter( + (gp) => gp.sheetNumber === sheetNumber && gp.side === side, + ); + + if (pagePositions.length > 0) { + addProofAnnotationsToPage(pages[pageIndex], pagePositions, geometry, fonts, election); + } + } + + return pdfDoc.save(); +} diff --git a/src/cli/config-runner.ts b/src/cli/config-runner.ts index d984378..f552113 100644 --- a/src/cli/config-runner.ts +++ b/src/cli/config-runner.ts @@ -29,8 +29,11 @@ import { runAdminConfigureWorkflow } from '../automation/admin-workflow.js'; import { runScanWorkflow, type BallotToScan } from '../automation/scan-workflow.js'; import { runAdminTallyWorkflow } from '../automation/admin-tally-workflow.js'; +// Proof ballot generation +import { generateProofBallot } from '../ballots/proof-ballot.js'; + // Reporting -import { createArtifactCollector } from '../report/artifacts.js'; +import { createArtifactCollector, PROOF_PREFIX } from '../report/artifacts.js'; import { generateHtmlReport } from '../report/html-generator.js'; import { join, dirname } from 'node:path'; import { sendWebhookUpdate } from '../webhook/client.js'; @@ -200,6 +203,15 @@ export async function runQAWorkflow(config: QARunConfig, options: RunOptions = { await writeFile(pdfPath, ballot.pdfData); + const proofPdfName = `${PROOF_PREFIX}${pdfName}`; + const proofPdfPath = join(ballotsPath, proofPdfName); + const proofPdfBytes = await generateProofBallot( + election, + ballot.ballotStyleId, + ballot.pdfData, + ); + await writeFile(proofPdfPath, proofPdfBytes); + collector.addBallot({ ballotStyleId: ballot.ballotStyleId, precinctId: ballot.precinctId, diff --git a/src/report/artifacts.ts b/src/report/artifacts.ts index 12f1121..e772eb1 100644 --- a/src/report/artifacts.ts +++ b/src/report/artifacts.ts @@ -20,6 +20,8 @@ import assert from 'node:assert'; import { Page } from '@playwright/test'; import { createScreenshotManager } from '../automation/screenshot.js'; +export const PROOF_PREFIX = 'PROOF-'; + export interface StepCollector { /** * Add an input to the current step diff --git a/src/report/html-generator.ts b/src/report/html-generator.ts index 2356b49..47a9d59 100644 --- a/src/report/html-generator.ts +++ b/src/report/html-generator.ts @@ -3,10 +3,10 @@ */ import Handlebars from 'handlebars'; -import { join, relative } from 'node:path'; +import { dirname, join, relative } from 'node:path'; import { logger } from '../utils/logger.js'; import type { ArtifactCollection, ScreenshotArtifact } from '../config/types.js'; -import { collectFilesInDir, loadCollection, readFileAsBase64 } from './artifacts.js'; +import { collectFilesInDir, loadCollection, PROOF_PREFIX } from './artifacts.js'; import { generatePdfThumbnail } from './pdf-thumbnail.js'; import { writeFile } from 'node:fs/promises'; import { resolvePath } from '../utils/paths.js'; @@ -70,18 +70,38 @@ async function prepareReportData( collection: ArtifactCollection, outputDir: string, ): Promise { - // Collect ballot images + // Collect ballot images, pairing base ballots with their proof counterparts const ballotsDir = join(outputDir, 'ballots'); - const ballotFiles = await collectFilesInDir(ballotsDir, ['.png', '.pdf']); - const ballots = await Promise.all( - ballotFiles.map(async (file) => ({ - name: file.name, - path: `ballots/${file.name}`, - thumbnail: file.name.endsWith('.pdf') - ? await generatePdfThumbnail(file.path) - : `data:image/png;base64,${await readFileAsBase64(file.path)}`, - })), + const ballotFiles = await collectFilesInDir(ballotsDir, ['.pdf']); + + async function makeBallotGalleryThumbnail(filePath: string): Promise { + return await generatePdfThumbnail(filePath, { scale: 2 }); + } + + const proofFileNames = new Set( + ballotFiles.filter((f) => f.name.startsWith(PROOF_PREFIX)).map((f) => f.name), ); + const baseFiles = ballotFiles.filter((f) => proofFileNames.has(`${PROOF_PREFIX}${f.name}`)); + + const ballotPairs: ReportData['ballotPairs'] = []; + + for (const base of baseFiles) { + const proofFileName = `${PROOF_PREFIX}${base.name}`; + + ballotPairs.push({ + name: base.name.replace(/\.pdf$/, '').replace(/^ballot-/, ''), + base: { + name: base.name, + path: `ballots/${base.name}`, + thumbnail: await makeBallotGalleryThumbnail(base.path), + }, + proof: { + name: proofFileName, + path: `ballots/${proofFileName}`, + thumbnail: await makeBallotGalleryThumbnail(join(dirname(base.path), proofFileName)), + }, + }); + } const validationResult = await validateTallyResults(collection); logger.info(`Tally validation: ${validationResult.message}`); @@ -231,7 +251,7 @@ async function prepareReportData( election: collection.config.election.source, }, steps, - ballots, + ballotPairs, scanResults: scanResults.map((r) => { const expected = r.expected; const actual = r.accepted; @@ -296,7 +316,11 @@ interface ReportData { hasErrors: boolean; errors: { step: string; message: string; timestamp: Date }[]; }[]; - ballots: { name: string; path: string; thumbnail: string | null }[]; + ballotPairs: { + name: string; + base: { name: string; path: string; thumbnail: string | null }; + proof: { name: string; path: string; thumbnail: string | null }; + }[]; scanResults: { ballotStyleId: string; ballotMode: string; @@ -399,6 +423,9 @@ function renderTemplate(data: ReportData): string { .status-error { color: var(--error); font-weight: 600; } .expected-marker { font-size: 0.75rem; color: var(--gray-700); } + .ballot-pair { background: white; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 1.5rem; padding: 1rem; } + .ballot-pair-label { font-weight: 600; margin-bottom: 0.75rem; color: var(--gray-700); font-size: 0.875rem; } + .ballot-pair-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; } .gallery-item { background: var(--gray-100); border-radius: 0.25rem; overflow: hidden; } .gallery-item a { display: block; cursor: pointer; } @@ -645,6 +672,33 @@ function renderTemplate(data: ReportData): string { {{/if}} {{/each}} + + {{#if ballotPairs.length}} +

Ballot Proof Gallery

+ {{#each ballotPairs}} +
+
{{name}}
+
+ + +
+
+ {{/each}} + {{/if}} `; diff --git a/src/report/pdf-thumbnail.ts b/src/report/pdf-thumbnail.ts index 4d4cf81..b9052c0 100644 --- a/src/report/pdf-thumbnail.ts +++ b/src/report/pdf-thumbnail.ts @@ -9,10 +9,12 @@ import { logger } from '../utils/logger.js'; * Generate a thumbnail of the first page of a PDF * Returns a data URL containing a base64-encoded PNG, or null if generation fails */ -export async function generatePdfThumbnail(pdfPath: string): Promise { +export async function generatePdfThumbnail( + pdfPath: string, + { scale = 0.5 }: { scale?: number } = {}, +): Promise { try { - // Convert PDF to image with scale 0.5 for thumbnail size - const document = await pdf(pdfPath, { scale: 0.5 }); + const document = await pdf(pdfPath, { scale }); // Get the first page for await (const image of document) { diff --git a/src/test/pdf-snapshot.ts b/src/test/pdf-snapshot.ts new file mode 100644 index 0000000..9adf643 --- /dev/null +++ b/src/test/pdf-snapshot.ts @@ -0,0 +1,35 @@ +/** + * PDF snapshot testing utility. + * + * Renders each page of a PDF to a PNG image and compares it against a stored + * snapshot using jest-image-snapshot. Similar to vxsuite's toMatchPdfSnapshot. + */ + +import { expect } from 'vitest'; +import { pdf } from 'pdf-to-img'; + +export interface PdfSnapshotOptions { + readonly customSnapshotIdentifier?: string; + readonly failureThreshold?: number; +} + +export async function expectToMatchPdfSnapshot( + pdfBytes: Uint8Array, + options: PdfSnapshotOptions = {}, +): Promise { + const pages = await pdf(Buffer.from(pdfBytes), { scale: 2 }); + let pageNumber = 0; + + for await (const pageImage of pages) { + pageNumber += 1; + const identifier = options.customSnapshotIdentifier + ? `${options.customSnapshotIdentifier}-page-${pageNumber}` + : undefined; + + expect(pageImage).toMatchImageSnapshot({ + failureThreshold: options.failureThreshold ?? 0, + failureThresholdType: 'percent', + customSnapshotIdentifier: identifier, + }); + } +} diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..2adc7ff --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,4 @@ +import { expect } from 'vitest'; +import { toMatchImageSnapshot } from 'jest-image-snapshot'; + +expect.extend({ toMatchImageSnapshot }); diff --git a/vitest.config.ts b/vitest.config.ts index 4f5bb43..a9fe8a4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,9 @@ export default defineConfig({ // Use node environment for testing environment: 'node', + // Setup files + setupFiles: ['src/test/setup.ts'], + // Include test files include: ['src/**/*.test.ts', 'src/**/*.spec.ts'],