Add frontend unit tests and harden the cropper's test coverage
Add Vitest (test:unit) with exact-value tests for the crop math (containScale, defaultSelection, clampSelection, all resizeSelection corners, and mime/filename resolution), and wire it plus the Pest browser test into CI. The browser test now samples output pixels against a four-quadrant fixture, so it detects a blank, flipped, or wrong crop rather than only asserting a 512x512 canvas. Add composer test:all to run PHP + Vitest + browser tests locally.
This commit is contained in:
parent
36ccc570e0
commit
ef81ea74a8
8 changed files with 617 additions and 3 deletions
12
.github/workflows/tests.yml
vendored
12
.github/workflows/tests.yml
vendored
|
|
@ -83,6 +83,9 @@ jobs:
|
|||
- name: Build assets
|
||||
run: npm run build
|
||||
|
||||
- name: Run frontend unit tests
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Run migrations
|
||||
run: php artisan migrate --force
|
||||
env:
|
||||
|
|
@ -97,3 +100,12 @@ jobs:
|
|||
env:
|
||||
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
|
||||
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run browser tests
|
||||
run: php artisan test tests/Browser --compact
|
||||
env:
|
||||
DB_PORT: ${{ job.services.postgres.ports['5432'] }}
|
||||
REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
|
||||
|
|
|
|||
|
|
@ -121,6 +121,11 @@
|
|||
"@test:lint",
|
||||
"@php artisan test"
|
||||
],
|
||||
"test:all": [
|
||||
"@test",
|
||||
"npm run test:unit",
|
||||
"@php artisan test tests/Browser"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
|
|
|
|||
382
package-lock.json
generated
382
package-lock.json
generated
|
|
@ -63,6 +63,7 @@
|
|||
"typescript": "^5.2.2",
|
||||
"typescript-eslint": "^8.23.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.9",
|
||||
"vue-tsc": "^2.2.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
|
@ -1670,6 +1671,13 @@
|
|||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
||||
|
|
@ -2036,6 +2044,17 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
|
|
@ -2361,6 +2380,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
|
@ -3187,6 +3213,136 @@
|
|||
"vue": "^3.2.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.9",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.9",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils/node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@volar/language-core": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
|
||||
|
|
@ -3787,6 +3943,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/async-function": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
|
||||
|
|
@ -3943,6 +4109,16 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
|
|
@ -5116,6 +5292,13 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
|
||||
"integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
|
|
@ -5720,6 +5903,16 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
|
@ -7638,6 +7831,20 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ohash": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||
|
|
@ -7787,6 +7994,13 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
|
||||
|
|
@ -8690,6 +8904,13 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
|
|
@ -8751,6 +8972,20 @@
|
|||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
|
|
@ -8984,6 +9219,23 @@
|
|||
"node": ">=12.22"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
|
@ -9036,6 +9288,16 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
@ -9554,6 +9816,109 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.9",
|
||||
"@vitest/mocker": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/runner": "4.1.9",
|
||||
"@vitest/snapshot": "4.1.9",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.9",
|
||||
"@vitest/browser-preview": "4.1.9",
|
||||
"@vitest/browser-webdriverio": "4.1.9",
|
||||
"@vitest/coverage-istanbul": "4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vitest/ui": "4.1.9",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
|
|
@ -9799,6 +10164,23 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
"dev": "vite",
|
||||
"format": "prettier --write resources/",
|
||||
"format:check": "prettier --check resources/",
|
||||
"lint": "eslint . --fix"
|
||||
"lint": "eslint . --fix",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
|
|
@ -36,6 +37,7 @@
|
|||
"typescript": "^5.2.2",
|
||||
"typescript-eslint": "^8.23.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.9",
|
||||
"vue-tsc": "^2.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
168
resources/js/lib/imageCrop.test.ts
Normal file
168
resources/js/lib/imageCrop.test.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
clampSelection,
|
||||
containScale,
|
||||
defaultSelection,
|
||||
resizeSelection,
|
||||
resolveOutputFileName,
|
||||
resolveOutputMime,
|
||||
} from '@/lib/imageCrop';
|
||||
|
||||
const VIEWPORT = 512;
|
||||
|
||||
describe('containScale', () => {
|
||||
it('fits a landscape image by its width', () => {
|
||||
expect(containScale(1000, 500, VIEWPORT)).toBeCloseTo(VIEWPORT / 1000, 6);
|
||||
});
|
||||
|
||||
it('fits a portrait image by its height', () => {
|
||||
expect(containScale(500, 1000, VIEWPORT)).toBeCloseTo(VIEWPORT / 1000, 6);
|
||||
});
|
||||
|
||||
it('returns 1:1 when the image already matches the viewport', () => {
|
||||
expect(containScale(VIEWPORT, VIEWPORT, VIEWPORT)).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to 1 for degenerate dimensions', () => {
|
||||
expect(containScale(0, 500, VIEWPORT)).toBe(1);
|
||||
expect(containScale(500, 0, VIEWPORT)).toBe(1);
|
||||
expect(containScale(-10, 500, VIEWPORT)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultSelection', () => {
|
||||
it('is a centered square inset from the edges of a landscape image', () => {
|
||||
expect(defaultSelection(1000, 500)).toEqual({ sx: 300, sy: 50, sw: 400, sh: 400 });
|
||||
});
|
||||
|
||||
it('is a centered square inset from the edges of a portrait image', () => {
|
||||
expect(defaultSelection(500, 1000)).toEqual({ sx: 50, sy: 300, sw: 400, sh: 400 });
|
||||
});
|
||||
|
||||
it('stays inset even when the image is already square, so the crop box is always visible', () => {
|
||||
expect(defaultSelection(800, 800)).toEqual({ sx: 80, sy: 80, sw: 640, sh: 640 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampSelection', () => {
|
||||
it('caps the size at the shorter image side', () => {
|
||||
expect(clampSelection({ sx: 0, sy: 0, sw: 9999, sh: 9999 }, 1000, 500, 50)).toEqual({
|
||||
sx: 0,
|
||||
sy: 0,
|
||||
sw: 500,
|
||||
sh: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('never shrinks below the minimum size', () => {
|
||||
expect(clampSelection({ sx: 0, sy: 0, sw: 10, sh: 10 }, 1000, 500, 50).sw).toBe(50);
|
||||
});
|
||||
|
||||
it('pulls a selection that ran off the right edge back inside', () => {
|
||||
const clamped = clampSelection({ sx: 900, sy: 0, sw: 500, sh: 500 }, 1000, 500, 50);
|
||||
|
||||
expect(clamped.sx).toBe(500);
|
||||
expect(clamped.sx + clamped.sw).toBe(1000);
|
||||
});
|
||||
|
||||
it('pulls negative offsets back to the origin', () => {
|
||||
expect(clampSelection({ sx: -100, sy: -100, sw: 300, sh: 300 }, 1000, 500, 50)).toEqual({
|
||||
sx: 0,
|
||||
sy: 0,
|
||||
sw: 300,
|
||||
sh: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it('pulls a selection that ran off the bottom edge back inside', () => {
|
||||
const clamped = clampSelection({ sx: 0, sy: 900, sw: 400, sh: 400 }, 500, 1000, 50);
|
||||
|
||||
expect(clamped.sy).toBe(600);
|
||||
expect(clamped.sy + clamped.sh).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resizeSelection', () => {
|
||||
const base = { sx: 200, sy: 100, sw: 200, sh: 200 };
|
||||
|
||||
it('grows from the anchored top-left corner when the vertical drag dominates (se)', () => {
|
||||
const resized = resizeSelection(base, 'se', 500, 500, 1000, 600, 50);
|
||||
|
||||
expect(resized).toEqual({ sx: 200, sy: 100, sw: 400, sh: 400 });
|
||||
});
|
||||
|
||||
it('sizes from the horizontal drag when it dominates (se)', () => {
|
||||
const resized = resizeSelection(base, 'se', 700, 150, 1000, 600, 50);
|
||||
|
||||
expect(resized).toEqual({ sx: 200, sy: 100, sw: 500, sh: 500 });
|
||||
});
|
||||
|
||||
it('anchors the top-right corner when dragging the bottom-left (sw)', () => {
|
||||
const resized = resizeSelection(base, 'sw', 50, 120, 1000, 600, 50);
|
||||
|
||||
expect(resized).toEqual({ sx: 50, sy: 100, sw: 350, sh: 350 });
|
||||
});
|
||||
|
||||
it('anchors the bottom-left corner when dragging the top-right (ne)', () => {
|
||||
const resized = resizeSelection(base, 'ne', 450, 50, 1000, 600, 50);
|
||||
|
||||
expect(resized).toEqual({ sx: 200, sy: 50, sw: 250, sh: 250 });
|
||||
});
|
||||
|
||||
it('anchors the bottom-right corner when dragging the top-left (nw)', () => {
|
||||
const resized = resizeSelection(base, 'nw', 100, 50, 1000, 600, 50);
|
||||
|
||||
expect(resized).toEqual({ sx: 100, sy: 0, sw: 300, sh: 300 });
|
||||
});
|
||||
|
||||
it('stays square whichever axis the pointer favours', () => {
|
||||
const resized = resizeSelection(base, 'se', 260, 900, 1000, 600, 50);
|
||||
|
||||
expect(resized.sw).toBe(resized.sh);
|
||||
});
|
||||
|
||||
it('honours the minimum size when collapsed', () => {
|
||||
const resized = resizeSelection(base, 'se', 210, 110, 1000, 600, 50);
|
||||
|
||||
expect(resized).toEqual({ sx: 200, sy: 100, sw: 50, sh: 50 });
|
||||
});
|
||||
|
||||
it('never escapes the image bounds under an extreme drag', () => {
|
||||
const resized = resizeSelection(base, 'ne', 5000, -5000, 1000, 600, 50);
|
||||
|
||||
expect(resized.sx).toBeGreaterThanOrEqual(0);
|
||||
expect(resized.sy).toBeGreaterThanOrEqual(0);
|
||||
expect(resized.sx + resized.sw).toBeLessThanOrEqual(1000);
|
||||
expect(resized.sy + resized.sh).toBeLessThanOrEqual(600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOutputMime', () => {
|
||||
it('passes through mimes the canvas can encode', () => {
|
||||
expect(resolveOutputMime('image/jpeg')).toBe('image/jpeg');
|
||||
expect(resolveOutputMime('image/png')).toBe('image/png');
|
||||
expect(resolveOutputMime('image/webp')).toBe('image/webp');
|
||||
});
|
||||
|
||||
it('coerces mimes the canvas cannot encode to png', () => {
|
||||
expect(resolveOutputMime('image/gif')).toBe('image/png');
|
||||
expect(resolveOutputMime('image/svg+xml')).toBe('image/png');
|
||||
expect(resolveOutputMime('image/avif')).toBe('image/png');
|
||||
expect(resolveOutputMime('')).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOutputFileName', () => {
|
||||
it('swaps the extension to match the output mime', () => {
|
||||
expect(resolveOutputFileName('logo.gif', 'image/png')).toBe('logo.png');
|
||||
expect(resolveOutputFileName('photo.jpeg', 'image/jpeg')).toBe('photo.jpg');
|
||||
expect(resolveOutputFileName('shot.PNG', 'image/webp')).toBe('shot.webp');
|
||||
});
|
||||
|
||||
it('handles names without a usable extension', () => {
|
||||
expect(resolveOutputFileName('photo', 'image/png')).toBe('photo.png');
|
||||
expect(resolveOutputFileName('photo.', 'image/png')).toBe('photo.png');
|
||||
expect(resolveOutputFileName('', 'image/png')).toBe('image.png');
|
||||
});
|
||||
});
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
*/
|
||||
function selectPhoto(mixed $page): void
|
||||
{
|
||||
$base64 = base64_encode((string) file_get_contents(base_path('tests/fixtures/blue-logo.png')));
|
||||
$base64 = base64_encode((string) file_get_contents(base_path('tests/fixtures/crop-quadrants.png')));
|
||||
|
||||
$page->script(<<<JS
|
||||
(async () => {
|
||||
|
|
@ -59,6 +59,7 @@ function recordUpload(mixed $page): void
|
|||
url: this.__url,
|
||||
keys: [...body.keys()],
|
||||
size: photo instanceof File ? photo.size : 0,
|
||||
type: photo instanceof File ? photo.type : null,
|
||||
};
|
||||
}
|
||||
return send.apply(this, arguments);
|
||||
|
|
@ -87,7 +88,23 @@ function recordUpload(mixed $page): void
|
|||
return 'null';
|
||||
}
|
||||
const bitmap = await createImageBitmap(window.__uploadFile);
|
||||
return JSON.stringify({ ...window.__uploadRequest, width: bitmap.width, height: bitmap.height });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const sample = (x, y) => Array.from(context.getImageData(x, y, 1, 1).data);
|
||||
return JSON.stringify({
|
||||
...window.__uploadRequest,
|
||||
width: bitmap.width,
|
||||
height: bitmap.height,
|
||||
pixels: {
|
||||
topLeft: sample(128, 128),
|
||||
topRight: sample(384, 128),
|
||||
bottomLeft: sample(128, 384),
|
||||
bottomRight: sample(384, 384),
|
||||
},
|
||||
});
|
||||
})();
|
||||
JS), true);
|
||||
|
||||
|
|
@ -96,6 +113,19 @@ function recordUpload(mixed $page): void
|
|||
->and($request['url'])->toContain(route('app.profile.upload-photo', absolute: false))
|
||||
->and($request['keys'])->toContain('photo')
|
||||
->and($request['size'])->toBeGreaterThan(0)
|
||||
->and($request['type'])->toBe('image/png')
|
||||
->and($request['width'])->toBe(512)
|
||||
->and($request['height'])->toBe(512);
|
||||
|
||||
$isColour = function (array $pixel, array $rgb): bool {
|
||||
return abs($pixel[0] - $rgb[0]) <= 24
|
||||
&& abs($pixel[1] - $rgb[1]) <= 24
|
||||
&& abs($pixel[2] - $rgb[2]) <= 24
|
||||
&& $pixel[3] >= 250;
|
||||
};
|
||||
|
||||
expect($isColour($request['pixels']['topLeft'], [255, 0, 0]))->toBeTrue()
|
||||
->and($isColour($request['pixels']['topRight'], [0, 255, 0]))->toBeTrue()
|
||||
->and($isColour($request['pixels']['bottomLeft'], [0, 0, 255]))->toBeTrue()
|
||||
->and($isColour($request['pixels']['bottomRight'], [255, 255, 0]))->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
BIN
tests/fixtures/crop-quadrants.png
vendored
Normal file
BIN
tests/fixtures/crop-quadrants.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 624 B |
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['resources/js/**/*.{test,spec}.ts'],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue